@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.js CHANGED
@@ -472,6 +472,76 @@ function validateRequest(request) {
472
472
  }
473
473
  return parsed.data;
474
474
  }
475
+ var LENGTH_RATIO_MIN = 0.35;
476
+ var LENGTH_RATIO_MAX = 3;
477
+ var LENGTH_RATIO_MIN_SOURCE_LENGTH = 12;
478
+ var UNICODE_LETTER = /\p{L}/u;
479
+ var DEGRADATION_NOTICE_CODES = /* @__PURE__ */ new Set([
480
+ "FORMALITY_DOWNGRADED",
481
+ "GLOSSARY_IGNORED"
482
+ ]);
483
+ function isLengthRatioOutlier(sourceValue, translatedValue) {
484
+ const trimmedSource = sourceValue.trim();
485
+ if (trimmedSource.length < LENGTH_RATIO_MIN_SOURCE_LENGTH) {
486
+ return false;
487
+ }
488
+ const ratio = translatedValue.trim().length / trimmedSource.length;
489
+ return ratio < LENGTH_RATIO_MIN || ratio > LENGTH_RATIO_MAX;
490
+ }
491
+ function isEqualsSource(input) {
492
+ const trimmedSource = input.sourceValue.trim();
493
+ const trimmedTranslated = input.translatedValue.trim();
494
+ return trimmedTranslated === trimmedSource && input.targetLocale !== input.sourceLocale && UNICODE_LETTER.test(trimmedSource);
495
+ }
496
+ function isGlossaryTermMissed(input) {
497
+ const glossary = input.glossary;
498
+ if (glossary === void 0 || Object.keys(glossary).length === 0) {
499
+ return false;
500
+ }
501
+ const sourceLower = input.sourceValue.toLowerCase();
502
+ const translatedLower = input.translatedValue.toLowerCase();
503
+ for (const [sourceTerm, targetTerm] of Object.entries(glossary)) {
504
+ const sourceHit = sourceLower.includes(sourceTerm.toLowerCase());
505
+ const targetHit = translatedLower.includes(targetTerm.toLowerCase());
506
+ if (sourceHit && !targetHit) {
507
+ return true;
508
+ }
509
+ }
510
+ return false;
511
+ }
512
+ function isIntegrityReordered(integrity) {
513
+ return integrity.matches && integrity.reordered;
514
+ }
515
+ function computeReviewFlags(input) {
516
+ const reasons = [];
517
+ if (isLengthRatioOutlier(input.sourceValue, input.translatedValue)) {
518
+ reasons.push("LENGTH_RATIO_OUTLIER");
519
+ }
520
+ if (isEqualsSource(input)) {
521
+ reasons.push("EQUALS_SOURCE");
522
+ }
523
+ if (isGlossaryTermMissed(input)) {
524
+ reasons.push("GLOSSARY_TERM_MISSED");
525
+ }
526
+ if (isIntegrityReordered(input.integrity)) {
527
+ reasons.push("INTEGRITY_REORDERED");
528
+ }
529
+ return reasons.length > 0 ? { status: "review", reasons } : void 0;
530
+ }
531
+ function applyProviderDegraded(reviewFlags, notices, acceptedKeys) {
532
+ if (!notices.some((notice) => DEGRADATION_NOTICE_CODES.has(notice.code))) {
533
+ return reviewFlags;
534
+ }
535
+ const next = new Map(reviewFlags);
536
+ for (const key of acceptedKeys) {
537
+ const existing = next.get(key);
538
+ next.set(key, {
539
+ status: "review",
540
+ reasons: existing !== void 0 ? [...existing.reasons, "PROVIDER_DEGRADED"] : ["PROVIDER_DEGRADED"]
541
+ });
542
+ }
543
+ return next;
544
+ }
475
545
  function toIntegrityInputs(entries, values) {
476
546
  const inputs = [];
477
547
  for (const entry of entries) {
@@ -567,7 +637,33 @@ async function runLlmTranslation(request, mechanism) {
567
637
  request.extractPlaceholders,
568
638
  request.comparePlaceholders
569
639
  );
570
- return usage === void 0 ? { values, integrity, notices: [] } : { values, integrity, usage, notices: [] };
640
+ const notices = [];
641
+ const reviewFlags = applyProviderDegraded(buildReviewFlags(data, values, integrity), notices, [
642
+ ...values.keys()
643
+ ]);
644
+ return usage === void 0 ? { values, integrity, notices, reviewFlags } : { values, integrity, usage, notices, reviewFlags };
645
+ }
646
+ function buildReviewFlags(data, values, integrity) {
647
+ const reviewFlags = /* @__PURE__ */ new Map();
648
+ for (const entry of data.entries) {
649
+ const translatedValue = values.get(entry.key);
650
+ const entryIntegrity = integrity.get(entry.key);
651
+ if (translatedValue === void 0 || entryIntegrity === void 0) {
652
+ continue;
653
+ }
654
+ const flag = computeReviewFlags({
655
+ sourceValue: entry.value,
656
+ translatedValue,
657
+ sourceLocale: data.sourceLocale,
658
+ targetLocale: data.targetLocale,
659
+ integrity: entryIntegrity,
660
+ glossary: data.glossary
661
+ });
662
+ if (flag !== void 0) {
663
+ reviewFlags.set(entry.key, flag);
664
+ }
665
+ }
666
+ return reviewFlags;
571
667
  }
572
668
  async function requestTranslations(mechanism, data, signal) {
573
669
  const payloadJson = JSON.stringify(buildDataPayload(data));
@@ -922,7 +1018,34 @@ async function translate(bundle, config, request) {
922
1018
  if (unprotectable.length > 0) {
923
1019
  notices.push({ code: "PLACEHOLDER_UNSUPPORTED", message: PLACEHOLDER_UNSUPPORTED_MESSAGE });
924
1020
  }
925
- return { values, integrity, notices };
1021
+ const reviewFlags = applyProviderDegraded(
1022
+ buildReviewFlags2(protectable, data, values, integrity, request.glossary),
1023
+ notices,
1024
+ [...values.keys()]
1025
+ );
1026
+ return { values, integrity, notices, reviewFlags };
1027
+ }
1028
+ function buildReviewFlags2(protectable, data, values, integrity, glossary) {
1029
+ const reviewFlags = /* @__PURE__ */ new Map();
1030
+ for (const entry of protectable) {
1031
+ const translatedValue = values.get(entry.key);
1032
+ const entryIntegrity = integrity.get(entry.key);
1033
+ if (translatedValue === void 0 || entryIntegrity === void 0) {
1034
+ continue;
1035
+ }
1036
+ const flag = computeReviewFlags({
1037
+ sourceValue: entry.value,
1038
+ translatedValue,
1039
+ sourceLocale: data.sourceLocale,
1040
+ targetLocale: data.targetLocale,
1041
+ integrity: entryIntegrity,
1042
+ glossary
1043
+ });
1044
+ if (flag !== void 0) {
1045
+ reviewFlags.set(entry.key, flag);
1046
+ }
1047
+ }
1048
+ return reviewFlags;
926
1049
  }
927
1050
  async function translateProtectable(client, data, protectable, options, extract, compare, signal) {
928
1051
  if (protectable.length === 0) {
@@ -1426,6 +1549,7 @@ function buildProvider(config) {
1426
1549
 
1427
1550
  // src/config/schema.ts
1428
1551
  var DEFAULT_MAX_BATCH_SIZE = 50;
1552
+ var DEFAULT_BUDGET_BEHAVIOR = "warn";
1429
1553
  function findCaseInsensitiveDuplicate(locales) {
1430
1554
  const seen = /* @__PURE__ */ new Set();
1431
1555
  for (const locale of locales) {
@@ -1474,7 +1598,23 @@ var verbatraConfigSchema = z.strictObject({
1474
1598
  * be a positive integer (zero, negative, or non-integer is rejected, never coerced). When absent,
1475
1599
  * {@link DEFAULT_MAX_BATCH_SIZE} applies.
1476
1600
  */
1477
- maxBatchSize: z.number().int().positive().optional()
1601
+ maxBatchSize: z.number().int().positive().optional(),
1602
+ /**
1603
+ * Optional whole-run token ceiling (input plus output tokens summed across every provider call, main
1604
+ * translation and plural generation alike, across all target locales). Checked after each completed
1605
+ * sub-batch, never mid-batch: the sub-batch whose completion crosses the ceiling is retained and
1606
+ * counted, since a call already in flight cannot be undone. Must be a positive integer. Config-only,
1607
+ * no CLI flag. Absent means no budget is enforced. Inert (never a false trip) against a token-less
1608
+ * provider such as DeepL, since it never reports usage to measure against the ceiling.
1609
+ */
1610
+ maxTokens: z.number().int().positive().optional(),
1611
+ /**
1612
+ * What happens once `maxTokens` is reached: `"warn"` (default) flags it and lets the run continue
1613
+ * unchanged; `"stop"` withholds every not-yet-attempted key for the rest of the run (the current
1614
+ * locale's remaining candidates and every later locale's), retried automatically next run. Present
1615
+ * without `maxTokens` is accepted and has no effect. Never changes the command's exit code.
1616
+ */
1617
+ budgetBehavior: z.enum(["warn", "stop"]).optional()
1478
1618
  }).refine((config) => !config.targetLocales.includes(config.sourceLocale), {
1479
1619
  message: "targetLocales must not include the source locale",
1480
1620
  path: ["targetLocales"]
@@ -1908,7 +2048,7 @@ var AdapterError = class extends Error {
1908
2048
  var MAX_DEPTH = 100;
1909
2049
  var MAX_INPUT_BYTES = 16 * 1024 * 1024;
1910
2050
  var jsonTreeSchema = z.lazy(
1911
- () => z.union([z.string(), z.record(z.string(), jsonTreeSchema)])
2051
+ () => z.union([z.string(), z.number(), z.boolean(), z.null(), z.record(z.string(), jsonTreeSchema)])
1912
2052
  );
1913
2053
  var rootSchema = z.record(z.string(), jsonTreeSchema);
1914
2054
  function assertWithinDepth(value, max) {
@@ -1936,7 +2076,7 @@ function assertJsonRecord(value) {
1936
2076
  if (!result.success) {
1937
2077
  throw new AdapterError(
1938
2078
  "INVALID_STRUCTURE",
1939
- "The file is not a valid object (expected nested objects of string values)."
2079
+ "The file is not a valid object (expected nested objects of string, number, boolean, or null leaves)."
1940
2080
  );
1941
2081
  }
1942
2082
  return result.data;
@@ -1954,6 +2094,9 @@ function serializeJsonTree(tree) {
1954
2094
  return `${JSON.stringify(tree, null, 2)}
1955
2095
  `;
1956
2096
  }
2097
+ function isJsonNode(value) {
2098
+ return typeof value === "object" && value !== null;
2099
+ }
1957
2100
  function namespaceOf(filePath) {
1958
2101
  return basename(filePath, extname(filePath));
1959
2102
  }
@@ -1981,9 +2124,25 @@ function buildCanHandle(extensions, sniff) {
1981
2124
  return sample === void 0 || sniff === void 0 || sniff(sample);
1982
2125
  };
1983
2126
  }
2127
+ async function fsyncPath(path) {
2128
+ const handle = await open(path, "r");
2129
+ try {
2130
+ await handle.sync();
2131
+ } finally {
2132
+ await handle.close();
2133
+ }
2134
+ }
2135
+ async function fsyncDirBestEffort(path) {
2136
+ try {
2137
+ await fsyncPath(path);
2138
+ } catch {
2139
+ }
2140
+ }
1984
2141
  var nodeOps = {
1985
2142
  writeFile: (path, data) => writeFile(path, data, "utf8"),
2143
+ fsyncFile: (path) => fsyncPath(path),
1986
2144
  rename: (from, to) => rename(from, to),
2145
+ fsyncDir: (path) => fsyncDirBestEffort(path),
1987
2146
  rm: (path) => rm(path, { force: true })
1988
2147
  };
1989
2148
  async function cleanup(ops, tmp) {
@@ -1999,11 +2158,16 @@ async function atomicWriteFile(path, data, ops = nodeOps) {
1999
2158
  const tmp = tempFileName2(path);
2000
2159
  try {
2001
2160
  await ops.writeFile(tmp, data);
2161
+ await ops.fsyncFile(tmp);
2002
2162
  await ops.rename(tmp, path);
2003
2163
  } catch (error) {
2004
2164
  await cleanup(ops, tmp);
2005
2165
  throw error;
2006
2166
  }
2167
+ try {
2168
+ await ops.fsyncDir(dirname(path));
2169
+ } catch {
2170
+ }
2007
2171
  }
2008
2172
  function stripLeadingBom(content) {
2009
2173
  return content.charCodeAt(0) === 65279 ? content.slice(1) : content;
@@ -2125,20 +2289,24 @@ function addLeaf(ctx, segments, key, value) {
2125
2289
  );
2126
2290
  }
2127
2291
  ctx.claimed.set(effectivePath, mapKey);
2292
+ if (typeof value !== "string") {
2293
+ ctx.excluded.push(mapKey);
2294
+ return;
2295
+ }
2128
2296
  const { placeholders, isPlural } = ctx.derive(key, value);
2129
2297
  ctx.out.set(mapKey, { key: mapKey, namespace: ctx.namespace, value, placeholders, isPlural });
2130
2298
  }
2131
2299
  function addEntries(ctx, prefix, node) {
2132
2300
  for (const [key, value] of Object.entries(node)) {
2133
2301
  const segments = [...prefix, key];
2134
- if (typeof value === "string") {
2135
- addLeaf(ctx, segments, key, value);
2136
- } else {
2302
+ if (isJsonNode(value)) {
2137
2303
  addEntries(ctx, segments, value);
2304
+ } else {
2305
+ addLeaf(ctx, segments, key, value);
2138
2306
  }
2139
2307
  }
2140
2308
  }
2141
- function addPathEntries(node, prefix, namespace, derive, out, claimedPaths) {
2309
+ function addPathEntries(node, prefix, namespace, derive, out, claimedPaths, excluded) {
2142
2310
  for (const [key, value] of Object.entries(node)) {
2143
2311
  const path = prefix === "" ? key : `${prefix}.${key}`;
2144
2312
  if (claimedPaths.has(path)) {
@@ -2148,22 +2316,26 @@ function addPathEntries(node, prefix, namespace, derive, out, claimedPaths) {
2148
2316
  );
2149
2317
  }
2150
2318
  claimedPaths.add(path);
2151
- if (typeof value === "string") {
2319
+ if (isJsonNode(value)) {
2320
+ addPathEntries(value, path, namespace, derive, out, claimedPaths, excluded);
2321
+ } else if (typeof value === "string") {
2152
2322
  const { placeholders, isPlural } = derive(key, value);
2153
2323
  out.set(path, { key: path, namespace, value, placeholders, isPlural });
2154
2324
  } else {
2155
- addPathEntries(value, path, namespace, derive, out, claimedPaths);
2325
+ excluded.push(path);
2156
2326
  }
2157
2327
  }
2158
2328
  }
2159
2329
  function flattenTree(tree, namespace, derive, keyMode = "literal-leaf") {
2160
2330
  const out = /* @__PURE__ */ new Map();
2161
2331
  if (keyMode === "path-notation") {
2162
- addPathEntries(tree, "", namespace, derive, out, /* @__PURE__ */ new Set());
2163
- return out;
2332
+ const excluded = [];
2333
+ addPathEntries(tree, "", namespace, derive, out, /* @__PURE__ */ new Set(), excluded);
2334
+ return { entries: out, excludedLeafPaths: excluded };
2164
2335
  }
2165
- addEntries({ namespace, derive, out, claimed: /* @__PURE__ */ new Map() }, [], tree);
2166
- return out;
2336
+ const ctx = { namespace, derive, out, claimed: /* @__PURE__ */ new Map(), excluded: [] };
2337
+ addEntries(ctx, [], tree);
2338
+ return { entries: out, excludedLeafPaths: ctx.excluded };
2167
2339
  }
2168
2340
  function emptyNode() {
2169
2341
  return /* @__PURE__ */ Object.create(null);
@@ -2216,9 +2388,9 @@ function toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTre
2216
2388
  try {
2217
2389
  const tree = parse3(content);
2218
2390
  validateTree?.(tree);
2219
- const entries = flattenTree(tree, namespace, deriveEntry, keyMode);
2220
- mergeDescriptions(entries, content, deriveDescriptions);
2221
- return entries;
2391
+ const result = flattenTree(tree, namespace, deriveEntry, keyMode);
2392
+ mergeDescriptions(result.entries, content, deriveDescriptions);
2393
+ return result;
2222
2394
  } catch (error) {
2223
2395
  rethrowStructured(error, "The file could not be parsed.");
2224
2396
  }
@@ -2249,7 +2421,7 @@ function createTreeFileAdapter(options) {
2249
2421
  async read(filePath, locale) {
2250
2422
  const content = await readFileContent(filePath);
2251
2423
  const namespace = namespaceOf(filePath);
2252
- const entries = toEntries(
2424
+ const { entries, excludedLeafPaths } = toEntries(
2253
2425
  content,
2254
2426
  namespace,
2255
2427
  parse3,
@@ -2260,7 +2432,7 @@ function createTreeFileAdapter(options) {
2260
2432
  );
2261
2433
  const resource = { locale, namespace, format, entries };
2262
2434
  const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
2263
- return { resource, invalidIcuKeys };
2435
+ return { resource, invalidIcuKeys, excludedLeafPaths };
2264
2436
  },
2265
2437
  async write(resource, filePath) {
2266
2438
  const tree = buildWriteTree ? await buildWriteTree(resource.entries, filePath) : unflattenEntries(resource.entries);
@@ -2354,8 +2526,10 @@ async function buildArbWriteTree(entries, filePath) {
2354
2526
  const translated = isMetadataKey(key) ? void 0 : messages.get(key);
2355
2527
  if (translated !== void 0) {
2356
2528
  consumed.add(key);
2529
+ out[key] = translated;
2530
+ } else if (isMetadataKey(key) || typeof value === "string") {
2531
+ out[key] = value;
2357
2532
  }
2358
- out[key] = translated ?? value;
2359
2533
  }
2360
2534
  for (const [key, value] of messages) {
2361
2535
  if (!consumed.has(key)) {
@@ -2449,7 +2623,7 @@ function createNextIntlJsonAdapter() {
2449
2623
  }
2450
2624
  function assertNoDottedNestedKey(tree) {
2451
2625
  for (const [key, value] of Object.entries(tree)) {
2452
- if (typeof value !== "object") {
2626
+ if (!isJsonNode(value)) {
2453
2627
  continue;
2454
2628
  }
2455
2629
  if (key.includes(".")) {
@@ -2465,7 +2639,7 @@ function assertNotMixed(tree) {
2465
2639
  let hasNested = false;
2466
2640
  let hasFlatDottedKey = false;
2467
2641
  for (const [key, value] of Object.entries(tree)) {
2468
- if (typeof value === "object") {
2642
+ if (isJsonNode(value)) {
2469
2643
  hasNested = true;
2470
2644
  } else if (key.includes(".")) {
2471
2645
  hasFlatDottedKey = true;
@@ -2626,7 +2800,7 @@ function createFlatFileAdapter(options) {
2626
2800
  const entries = toEntries2(content, namespace, parseEntries);
2627
2801
  const resource = { locale, namespace, format, entries };
2628
2802
  const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
2629
- return { resource, invalidIcuKeys };
2803
+ return { resource, invalidIcuKeys, excludedLeafPaths: [] };
2630
2804
  },
2631
2805
  async write(resource, filePath) {
2632
2806
  const data = await serializeEntries(resource.entries, filePath);
@@ -3080,6 +3254,46 @@ function selectProvider(config, createProvider = buildProvider) {
3080
3254
  }
3081
3255
  }
3082
3256
 
3257
+ // src/flow/budget.ts
3258
+ function createBudgetTracker(maxTokens, behavior) {
3259
+ return { maxTokens, behavior, tokensUsed: 0, usageSeen: false, exceeded: false, stopped: false };
3260
+ }
3261
+ function foldTrackerUsage(tracker, usage) {
3262
+ if (usage === void 0) {
3263
+ return;
3264
+ }
3265
+ tracker.usageSeen = true;
3266
+ tracker.tokensUsed += usage.inputTokens + usage.outputTokens;
3267
+ }
3268
+ function checkBudgetTrip(tracker) {
3269
+ if (tracker.maxTokens === void 0 || tracker.exceeded || tracker.tokensUsed < tracker.maxTokens) {
3270
+ return false;
3271
+ }
3272
+ tracker.exceeded = true;
3273
+ if (tracker.behavior === "stop") {
3274
+ tracker.stopped = true;
3275
+ }
3276
+ return true;
3277
+ }
3278
+ function toBudgetSummary(tracker) {
3279
+ if (tracker.maxTokens === void 0) {
3280
+ return void 0;
3281
+ }
3282
+ return {
3283
+ maxTokens: tracker.maxTokens,
3284
+ behavior: tracker.behavior,
3285
+ supported: tracker.usageSeen,
3286
+ tokensUsed: tracker.tokensUsed,
3287
+ exceeded: tracker.exceeded
3288
+ };
3289
+ }
3290
+ function budgetExceededNotice(tracker) {
3291
+ return {
3292
+ code: "BUDGET_TOKENS_EXCEEDED",
3293
+ message: `The run's cumulative token usage (${tracker.tokensUsed}) reached the configured budget of ${tracker.maxTokens} tokens (behavior: ${tracker.behavior}).`
3294
+ };
3295
+ }
3296
+
3083
3297
  // src/flow/locale-failure.ts
3084
3298
  function describeError(error) {
3085
3299
  if (error instanceof Error) {
@@ -3099,8 +3313,10 @@ function failureSummary(locale, error) {
3099
3313
  invalidIcuSource: [],
3100
3314
  integrityMismatches: [],
3101
3315
  providerFailures: [],
3316
+ budgetWithheld: [],
3102
3317
  generated: [],
3103
3318
  notices: [],
3319
+ needsReview: [],
3104
3320
  error: describeError(error)
3105
3321
  };
3106
3322
  }
@@ -3270,7 +3486,43 @@ function planPluralGeneration(source, targetLocale, format) {
3270
3486
  return { items };
3271
3487
  }
3272
3488
 
3489
+ // src/flow/usage.ts
3490
+ function createUsageAccumulator() {
3491
+ return { total: void 0 };
3492
+ }
3493
+ function foldUsage(accumulator, usage) {
3494
+ if (usage === void 0) {
3495
+ return;
3496
+ }
3497
+ const prior = accumulator.total ?? { inputTokens: 0, outputTokens: 0 };
3498
+ accumulator.total = {
3499
+ inputTokens: prior.inputTokens + usage.inputTokens,
3500
+ outputTokens: prior.outputTokens + usage.outputTokens
3501
+ };
3502
+ }
3503
+ function combineUsage(a, b) {
3504
+ if (a === void 0) {
3505
+ return b;
3506
+ }
3507
+ if (b === void 0) {
3508
+ return a;
3509
+ }
3510
+ return {
3511
+ inputTokens: a.inputTokens + b.inputTokens,
3512
+ outputTokens: a.outputTokens + b.outputTokens
3513
+ };
3514
+ }
3515
+
3273
3516
  // src/flow/plural-generation.ts
3517
+ var EMPTY_RESULT = {
3518
+ accepted: [],
3519
+ withheld: [],
3520
+ providerFailures: [],
3521
+ budgetWithheld: [],
3522
+ notices: [],
3523
+ usage: void 0,
3524
+ tripped: false
3525
+ };
3274
3526
  function generatedLockHash(governingEntries, category) {
3275
3527
  const governingHashes = governingEntries.map(contentHash).sort();
3276
3528
  return contentHash({
@@ -3309,23 +3561,45 @@ async function generatePluralForms(context) {
3309
3561
  const plan = planPluralGeneration(context.source, context.targetLocale, context.format);
3310
3562
  const stale = staleItems(plan.items, context.baseline);
3311
3563
  if (stale.length === 0) {
3312
- return { accepted: [], withheld: [], providerFailures: [], notices: [] };
3564
+ return EMPTY_RESULT;
3313
3565
  }
3314
3566
  const accepted = [];
3315
3567
  const withheld = [];
3316
3568
  const providerFailures = [];
3569
+ const budgetWithheld = [];
3317
3570
  const notices = [];
3571
+ const usage = createUsageAccumulator();
3572
+ let tripped = false;
3318
3573
  for (const batch of chunk(stale, context.maxBatchSize)) {
3319
- const batchNotices = await runGenerationSubBatch(
3574
+ if (context.budget.stopped) {
3575
+ for (const item of batch) {
3576
+ budgetWithheld.push(item.targetKey);
3577
+ }
3578
+ continue;
3579
+ }
3580
+ const subResult = await runGenerationSubBatch(
3320
3581
  context,
3321
3582
  batch,
3322
3583
  accepted,
3323
3584
  withheld,
3324
3585
  providerFailures
3325
3586
  );
3326
- notices.push(...batchNotices);
3587
+ notices.push(...subResult.notices);
3588
+ foldUsage(usage, subResult.usage);
3589
+ foldTrackerUsage(context.budget, subResult.usage);
3590
+ if (checkBudgetTrip(context.budget)) {
3591
+ tripped = true;
3592
+ }
3327
3593
  }
3328
- return { accepted, withheld, providerFailures, notices };
3594
+ return {
3595
+ accepted,
3596
+ withheld,
3597
+ providerFailures,
3598
+ budgetWithheld,
3599
+ notices,
3600
+ usage: usage.total,
3601
+ tripped
3602
+ };
3329
3603
  }
3330
3604
  async function runGenerationSubBatch(context, batch, accepted, withheld, providerFailures) {
3331
3605
  let result;
@@ -3336,12 +3610,12 @@ async function runGenerationSubBatch(context, batch, accepted, withheld, provide
3336
3610
  for (const item of batch) {
3337
3611
  providerFailures.push(item.targetKey);
3338
3612
  }
3339
- return [subBatchFailedNotice(batch.length, error)];
3613
+ return { notices: [subBatchFailedNotice(batch.length, error)], usage: void 0 };
3340
3614
  }
3341
3615
  for (const item of batch) {
3342
3616
  foldGenerationItem(item, result, accepted, withheld, providerFailures);
3343
3617
  }
3344
- return readNotices(result);
3618
+ return { notices: readNotices(result), usage: result.usage };
3345
3619
  }
3346
3620
  function foldGenerationItem(item, result, accepted, withheld, providerFailures) {
3347
3621
  const value = result.values.get(item.targetKey);
@@ -3409,6 +3683,7 @@ async function runLocale(params) {
3409
3683
  generated: [],
3410
3684
  integrityMismatches: [],
3411
3685
  providerFailures: [],
3686
+ budgetWithheld: [],
3412
3687
  pruned,
3413
3688
  notices: sdkNotices
3414
3689
  }),
@@ -3416,16 +3691,21 @@ async function runLocale(params) {
3416
3691
  };
3417
3692
  }
3418
3693
  const entries = toTranslate.map((key) => params.source.entries.get(key)).filter((entry) => entry !== void 0);
3694
+ const startedStopped = params.budget.stopped;
3419
3695
  const accepted = /* @__PURE__ */ new Map();
3420
3696
  const integrityMismatches = [];
3421
3697
  const providerFailures = [];
3422
- const subBatchNotices = await translateAndCheck(
3698
+ const budgetWithheld = [];
3699
+ const reviewFlags = /* @__PURE__ */ new Map();
3700
+ const translation = await translateAndCheck(
3423
3701
  provider,
3424
3702
  params,
3425
3703
  entries,
3426
3704
  accepted,
3427
3705
  integrityMismatches,
3428
- providerFailures
3706
+ providerFailures,
3707
+ budgetWithheld,
3708
+ reviewFlags
3429
3709
  );
3430
3710
  const merged = new Map(target.entries);
3431
3711
  for (const key of pruned) {
@@ -3451,16 +3731,20 @@ async function runLocale(params) {
3451
3731
  const pluralNotices = params.generatePlurals ? pluralNoticeFor(params, merged) : sdkNotices;
3452
3732
  const notices = [
3453
3733
  ...pluralNotices,
3454
- ...subBatchNotices,
3455
- ...generation.notices
3734
+ ...translation.notices,
3735
+ ...generation.notices,
3736
+ ...budgetLocaleNotices(params.budget, startedStopped, translation.tripped, generation.tripped)
3456
3737
  ];
3457
3738
  const withheld = /* @__PURE__ */ new Set([
3458
3739
  ...integrityMismatches,
3459
3740
  ...providerFailures,
3460
3741
  ...invalidIcuSource,
3461
3742
  ...generation.withheld,
3462
- ...generation.providerFailures
3743
+ ...generation.providerFailures,
3744
+ // Source-present keys withheld by the budget guardrail must keep their prior lock hash too.
3745
+ ...budgetWithheld
3463
3746
  ]);
3747
+ const localeUsage = combineUsage(translation.usage, generation.usage);
3464
3748
  return {
3465
3749
  summary: baseSummary({
3466
3750
  locale: params.targetLocale,
@@ -3473,15 +3757,27 @@ async function runLocale(params) {
3473
3757
  integrityMismatches: [...integrityMismatches, ...generation.withheld].sort(),
3474
3758
  // A generation sub-batch whose provider call itself threw is a provider failure, never integrity.
3475
3759
  providerFailures: [...providerFailures, ...generation.providerFailures].sort(),
3760
+ budgetWithheld: [...budgetWithheld, ...generation.budgetWithheld].sort(),
3476
3761
  pruned,
3477
- notices
3762
+ notices,
3763
+ needsReview: needsReviewFor(accepted.keys(), reviewFlags),
3764
+ ...localeUsage !== void 0 ? { usage: localeUsage } : {}
3478
3765
  }),
3479
3766
  lockEntries: computeLockEntries(params, merged, withheld, generation.accepted)
3480
3767
  };
3481
3768
  }
3769
+ var NO_GENERATION_RESULT = {
3770
+ accepted: [],
3771
+ withheld: [],
3772
+ providerFailures: [],
3773
+ budgetWithheld: [],
3774
+ notices: [],
3775
+ usage: void 0,
3776
+ tripped: false
3777
+ };
3482
3778
  async function runGeneration(params, provider) {
3483
3779
  if (!params.generatePlurals || provider.kind !== "llm") {
3484
- return { accepted: [], withheld: [], providerFailures: [], notices: [] };
3780
+ return NO_GENERATION_RESULT;
3485
3781
  }
3486
3782
  return generatePluralForms({
3487
3783
  source: params.source,
@@ -3493,9 +3789,13 @@ async function runGeneration(params, provider) {
3493
3789
  glossary: params.glossary,
3494
3790
  tone: params.tone,
3495
3791
  baseline: params.baseline,
3496
- maxBatchSize: params.maxBatchSize
3792
+ maxBatchSize: params.maxBatchSize,
3793
+ budget: params.budget
3497
3794
  });
3498
3795
  }
3796
+ function budgetLocaleNotices(budget, startedStopped, mainTripped, generationTripped) {
3797
+ return startedStopped || mainTripped || generationTripped ? [budgetExceededNotice(budget)] : [];
3798
+ }
3499
3799
  function pluralNoticeFor(params, merged) {
3500
3800
  if (params.format !== "i18next-json") {
3501
3801
  return [];
@@ -3516,26 +3816,53 @@ function baseSummary(parts) {
3516
3816
  invalidIcuSource: parts.invalidIcuSource,
3517
3817
  integrityMismatches: parts.integrityMismatches,
3518
3818
  providerFailures: parts.providerFailures,
3819
+ budgetWithheld: parts.budgetWithheld,
3519
3820
  generated: parts.generated,
3520
- notices: parts.notices
3821
+ notices: parts.notices,
3822
+ needsReview: parts.needsReview ?? [],
3823
+ ...parts.usage !== void 0 ? { usage: parts.usage } : {}
3521
3824
  };
3522
3825
  }
3523
- async function translateAndCheck(provider, params, entries, accepted, integrityMismatches, providerFailures) {
3826
+ function needsReviewFor(acceptedKeys, reviewFlags) {
3827
+ const entries = [];
3828
+ for (const key of acceptedKeys) {
3829
+ const flag = reviewFlags.get(key);
3830
+ if (flag !== void 0) {
3831
+ entries.push({ key, reasons: flag.reasons });
3832
+ }
3833
+ }
3834
+ return entries.sort((a, b) => a.key < b.key ? -1 : 1);
3835
+ }
3836
+ async function translateAndCheck(provider, params, entries, accepted, integrityMismatches, providerFailures, budgetWithheld, reviewFlags) {
3524
3837
  const notices = [];
3838
+ const usage = createUsageAccumulator();
3839
+ let tripped = false;
3525
3840
  for (const batch of chunk(entries, params.maxBatchSize)) {
3526
- const subNotices = await runSubBatch(
3841
+ if (params.budget.stopped) {
3842
+ for (const entry of batch) {
3843
+ budgetWithheld.push(entry.key);
3844
+ }
3845
+ continue;
3846
+ }
3847
+ const subResult = await runSubBatch(
3527
3848
  provider,
3528
3849
  params,
3529
3850
  batch,
3530
3851
  accepted,
3531
3852
  integrityMismatches,
3532
- providerFailures
3853
+ providerFailures,
3854
+ reviewFlags
3533
3855
  );
3534
- notices.push(...subNotices);
3856
+ notices.push(...subResult.notices);
3857
+ foldUsage(usage, subResult.usage);
3858
+ foldTrackerUsage(params.budget, subResult.usage);
3859
+ if (checkBudgetTrip(params.budget)) {
3860
+ tripped = true;
3861
+ }
3535
3862
  }
3536
- return notices;
3863
+ return { notices, tripped, usage: usage.total };
3537
3864
  }
3538
- async function runSubBatch(provider, params, batch, accepted, integrityMismatches, providerFailures) {
3865
+ async function runSubBatch(provider, params, batch, accepted, integrityMismatches, providerFailures, reviewFlags) {
3539
3866
  let result;
3540
3867
  try {
3541
3868
  result = await provider.translateBatch(buildRequest3(params, batch));
@@ -3543,12 +3870,17 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
3543
3870
  for (const entry of batch) {
3544
3871
  providerFailures.push(entry.key);
3545
3872
  }
3546
- return [subBatchFailedNotice(batch.length, error)];
3873
+ return { notices: [subBatchFailedNotice(batch.length, error)], usage: void 0 };
3547
3874
  }
3548
3875
  for (const entry of batch) {
3549
3876
  foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures);
3550
3877
  }
3551
- return readNotices(result);
3878
+ if (result.reviewFlags !== void 0) {
3879
+ for (const [key, flag] of result.reviewFlags) {
3880
+ reviewFlags.set(key, flag);
3881
+ }
3882
+ }
3883
+ return { notices: readNotices(result), usage: result.usage };
3552
3884
  }
3553
3885
  function foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures) {
3554
3886
  const value = result.values.get(entry.key);
@@ -3606,6 +3938,10 @@ async function translate2(input, deps = {}) {
3606
3938
  const generatePlurals = input.generatePlurals ?? config.generatePlurals ?? false;
3607
3939
  const maxBatchSize = config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
3608
3940
  const fs = deps.fs ?? defaultFs;
3941
+ const budget = createBudgetTracker(
3942
+ config.maxTokens,
3943
+ config.budgetBehavior ?? DEFAULT_BUDGET_BEHAVIOR
3944
+ );
3609
3945
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
3610
3946
  const provider = dryRun ? void 0 : selectProvider(config.provider, deps.createProvider);
3611
3947
  const source = await readSource(config, cwd, fs, adapter);
@@ -3630,7 +3966,8 @@ async function translate2(input, deps = {}) {
3630
3966
  prune,
3631
3967
  generatePlurals,
3632
3968
  maxBatchSize,
3633
- fs
3969
+ fs,
3970
+ budget
3634
3971
  };
3635
3972
  const { summary, lockEntries } = await runLocale(params);
3636
3973
  if (!dryRun) {
@@ -3643,7 +3980,19 @@ async function translate2(input, deps = {}) {
3643
3980
  }
3644
3981
  }
3645
3982
  const { succeeded, failed } = partition(summaries);
3646
- return { dryRun, locales: summaries, succeeded, failed };
3983
+ const usage = summaries.reduce(
3984
+ (total, summary) => combineUsage(total, summary.usage),
3985
+ void 0
3986
+ );
3987
+ const budgetSummary = toBudgetSummary(budget);
3988
+ return {
3989
+ dryRun,
3990
+ locales: summaries,
3991
+ succeeded,
3992
+ failed,
3993
+ ...usage !== void 0 ? { usage } : {},
3994
+ ...budgetSummary !== void 0 ? { budget: budgetSummary } : {}
3995
+ };
3647
3996
  }
3648
3997
  var ExchangeError = class extends Error {
3649
3998
  code;
@@ -3684,7 +4033,9 @@ var COLUMN = {
3684
4033
  status: 4,
3685
4034
  translation: 5,
3686
4035
  sourceHash: 6,
3687
- context: 7
4036
+ context: 7,
4037
+ reviewStatus: 8,
4038
+ reviewReasons: 9
3688
4039
  };
3689
4040
  var HEADERS = [
3690
4041
  "Key",
@@ -3693,7 +4044,9 @@ var HEADERS = [
3693
4044
  "Status",
3694
4045
  "Translation",
3695
4046
  "Source hash",
3696
- "Context"
4047
+ "Context",
4048
+ "Review status",
4049
+ "Review reasons"
3697
4050
  ];
3698
4051
  var HEADER_ROW = 1;
3699
4052
  var INSTRUCTIONS_SHEET_NAME = "Instructions";
@@ -3713,7 +4066,9 @@ var COLUMN_WIDTHS = {
3713
4066
  [COLUMN.current]: 50,
3714
4067
  [COLUMN.status]: 12,
3715
4068
  [COLUMN.translation]: 50,
3716
- [COLUMN.context]: 50
4069
+ [COLUMN.context]: 50,
4070
+ [COLUMN.reviewStatus]: 12,
4071
+ [COLUMN.reviewReasons]: 40
3717
4072
  };
3718
4073
  var TEXT_NUMBER_FORMAT = "@";
3719
4074
  function styleHeader(sheet) {
@@ -3745,7 +4100,9 @@ function writeRow(sheet, sheetRow) {
3745
4100
  translationCell.value = sheetRow.translation === "" ? null : sheetRow.translation;
3746
4101
  row.getCell(COLUMN.sourceHash).value = sheetRow.sourceHash;
3747
4102
  row.getCell(COLUMN.context).value = sheetRow.context;
3748
- for (let column = COLUMN.key; column <= COLUMN.context; column += 1) {
4103
+ row.getCell(COLUMN.reviewStatus).value = sheetRow.reviewStatus;
4104
+ row.getCell(COLUMN.reviewReasons).value = sheetRow.reviewReasons;
4105
+ for (let column = COLUMN.key; column <= COLUMN.reviewReasons; column += 1) {
3749
4106
  const cell = row.getCell(column);
3750
4107
  cell.protection = { locked: column !== COLUMN.translation };
3751
4108
  if (column !== COLUMN.translation) {
@@ -3920,7 +4277,11 @@ var rowSchema = z.object({
3920
4277
  status: z.enum(["new", "changed", "unchanged"]),
3921
4278
  sourceHash: z.string(),
3922
4279
  translation: z.string(),
3923
- context: z.string()
4280
+ context: z.string(),
4281
+ // A legacy workbook exported before these columns existed has no such cell; cellString then yields
4282
+ // "", and .catch maps it to "ok" / "" rather than rejecting the row.
4283
+ reviewStatus: z.enum(["ok", "review"]).catch("ok"),
4284
+ reviewReasons: z.string().catch("")
3924
4285
  });
3925
4286
  function cellString(cell) {
3926
4287
  const value = cell.value;
@@ -3956,7 +4317,10 @@ function parseRow(sheet, row) {
3956
4317
  translation: cellString(row.getCell(COLUMN.translation)),
3957
4318
  // A workbook built before the Context column existed has no cell here; getCell auto-vivifies an
3958
4319
  // empty one, so cellString yields "" and the row still validates, keeping import backward-compatible.
3959
- context: cellString(row.getCell(COLUMN.context))
4320
+ context: cellString(row.getCell(COLUMN.context)),
4321
+ // Same backward-compatible pattern for a workbook built before the Review columns existed.
4322
+ reviewStatus: cellString(row.getCell(COLUMN.reviewStatus)),
4323
+ reviewReasons: cellString(row.getCell(COLUMN.reviewReasons))
3960
4324
  };
3961
4325
  const result = rowSchema.safeParse(candidate);
3962
4326
  if (!result.success) {
@@ -4030,7 +4394,34 @@ async function readTarget3(cwd, config, adapter, fs, locale) {
4030
4394
  }
4031
4395
  return (await adapter.read(path, locale)).resource;
4032
4396
  }
4033
- function buildRows(source, target, baseline, includeUnchanged) {
4397
+ function reasonLabel(reason) {
4398
+ return reason.toLowerCase().replace(/_/g, "-");
4399
+ }
4400
+ function reviewColumns(flag) {
4401
+ if (flag === void 0) {
4402
+ return { reviewStatus: "ok", reviewReasons: "" };
4403
+ }
4404
+ return { reviewStatus: "review", reviewReasons: flag.reasons.map(reasonLabel).join(", ") };
4405
+ }
4406
+ function computeRowReview(adapter, sourceValue, currentTarget, sourceLocale, targetLocale, glossary) {
4407
+ if (currentTarget === "") {
4408
+ return { reviewStatus: "ok", reviewReasons: "" };
4409
+ }
4410
+ const integrity = adapter.comparePlaceholders?.(sourceValue, currentTarget) ?? checkPlaceholders(
4411
+ adapter.extractPlaceholders(sourceValue),
4412
+ adapter.extractPlaceholders(currentTarget)
4413
+ );
4414
+ const flag = computeReviewFlags({
4415
+ sourceValue,
4416
+ translatedValue: currentTarget,
4417
+ sourceLocale,
4418
+ targetLocale,
4419
+ integrity,
4420
+ glossary
4421
+ });
4422
+ return reviewColumns(flag);
4423
+ }
4424
+ function buildRows(source, target, baseline, includeUnchanged, adapter, glossary) {
4034
4425
  const diff2 = diffResources(source, target, { baseline });
4035
4426
  const rows = [];
4036
4427
  const add = (keys, status) => {
@@ -4039,14 +4430,23 @@ function buildRows(source, target, baseline, includeUnchanged) {
4039
4430
  if (sourceEntry === void 0) {
4040
4431
  continue;
4041
4432
  }
4433
+ const currentTarget = target.entries.get(key)?.value ?? "";
4042
4434
  rows.push({
4043
4435
  key,
4044
4436
  source: sourceEntry.value,
4045
- currentTarget: target.entries.get(key)?.value ?? "",
4437
+ currentTarget,
4046
4438
  status,
4047
4439
  sourceHash: contentHash(sourceEntry),
4048
4440
  translation: "",
4049
- context: sourceEntry.description ?? ""
4441
+ context: sourceEntry.description ?? "",
4442
+ ...computeRowReview(
4443
+ adapter,
4444
+ sourceEntry.value,
4445
+ currentTarget,
4446
+ source.locale,
4447
+ target.locale,
4448
+ glossary
4449
+ )
4050
4450
  });
4051
4451
  }
4052
4452
  };
@@ -4072,7 +4472,9 @@ async function exportWorkbook(input, deps = {}) {
4072
4472
  source.resource,
4073
4473
  target,
4074
4474
  baselineFor(lock, locale),
4075
- input.includeUnchanged ?? false
4475
+ input.includeUnchanged ?? false,
4476
+ adapter,
4477
+ config.glossary
4076
4478
  );
4077
4479
  return { locale, rows };
4078
4480
  })
@@ -4173,9 +4575,14 @@ function importLocale(params) {
4173
4575
  integrityMismatches: [...buckets.mismatches].sort(),
4174
4576
  // A workbook import never calls a provider, so a provider-call failure cannot occur here.
4175
4577
  providerFailures: [],
4578
+ // A workbook import never calls a provider, so the budget guardrail never withholds anything here.
4579
+ budgetWithheld: [],
4176
4580
  // Plural generation is a translate-flow concern; the manual workbook import never generates forms.
4177
4581
  generated: [],
4178
- notices: buckets.blankDrifted.size > 0 ? [blankRowBaselineNotice(buckets.blankDrifted.size)] : []
4582
+ notices: buckets.blankDrifted.size > 0 ? [blankRowBaselineNotice(buckets.blankDrifted.size)] : [],
4583
+ // A workbook import never calls a provider and never recomputes review flags on its own path; the
4584
+ // Excel workbook's Review status/reasons columns are informational only (see export-workbook.ts).
4585
+ needsReview: []
4179
4586
  };
4180
4587
  return { summary, accepted: buckets.accepted, withheld: buckets.withheld };
4181
4588
  }