@verbatra/sdk 0.6.0 → 0.6.1

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
@@ -329,77 +329,76 @@ function checkPlaceholders(source, translated) {
329
329
  reordered
330
330
  };
331
331
  }
332
- var LOCALE_TOKEN = "{locale}";
333
- function localeFilePath(cwd, pattern, locale) {
334
- return resolve(cwd, pattern.replaceAll(LOCALE_TOKEN, locale));
335
- }
336
- var RATE_LIMITED_STATUS = 429;
337
- var TIMEOUT_STATUS = 408;
338
- var AUTH_FAILED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
339
- var RATE_LIMITED_CLASS_NAMES = /* @__PURE__ */ new Set([
340
- "RateLimitError",
341
- "TooManyRequestsError"
342
- ]);
343
- var AUTH_FAILED_CLASS_NAMES = /* @__PURE__ */ new Set([
344
- "AuthenticationError",
345
- "PermissionDeniedError",
346
- "AuthorizationError"
347
- ]);
348
- var TIMEOUT_CLASS_NAMES = /* @__PURE__ */ new Set([
349
- "APIConnectionTimeoutError",
350
- "ConnectionError"
351
- ]);
352
- var ABORT_ERROR_CLASS_NAMES = /* @__PURE__ */ new Set(["APIUserAbortError"]);
353
- function getErrorStatus(error) {
354
- if (typeof error !== "object" || error === null || !("status" in error)) {
355
- return void 0;
332
+ var MIN_SOURCE_LENGTH = 8;
333
+ var MAX_LENGTH_MULTIPLE = 12;
334
+ var MAX_REPEAT_UNIT_LENGTH = 16;
335
+ var MIN_CONSECUTIVE_REPEATS = 8;
336
+ var REPEAT_COVERAGE_FRACTION = 0.5;
337
+ var MAX_SCAN_LENGTH = 8192;
338
+ function matchesUnit(text, a, b, unit) {
339
+ for (let i = 0; i < unit; i++) {
340
+ if (text[a + i] !== text[b + i]) {
341
+ return false;
342
+ }
356
343
  }
357
- const status = error.status;
358
- return typeof status === "number" ? status : void 0;
344
+ return true;
359
345
  }
360
- function readClassName(error) {
361
- if (typeof error !== "object" || error === null) {
362
- return void 0;
346
+ function countConsecutiveCopies(text, start, unit, scanLength) {
347
+ let copies = 1;
348
+ let next = start + unit;
349
+ while (next + unit <= scanLength && matchesUnit(text, start, next, unit)) {
350
+ copies++;
351
+ next += unit;
352
+ }
353
+ return copies;
354
+ }
355
+ function hasDominantRun(text, scanLength, unit, coverage) {
356
+ const limit = scanLength - unit;
357
+ let start = 0;
358
+ while (start <= limit) {
359
+ const copies = countConsecutiveCopies(text, start, unit, scanLength);
360
+ if (copies >= MIN_CONSECUTIVE_REPEATS && copies * unit >= coverage) {
361
+ return true;
362
+ }
363
+ start += copies * unit;
363
364
  }
364
- return error.constructor?.name;
365
+ return false;
365
366
  }
366
- function classifyProviderError(error) {
367
- const status = getErrorStatus(error);
368
- if (status === RATE_LIMITED_STATUS) {
369
- return "RATE_LIMITED";
370
- }
371
- if (status !== void 0 && AUTH_FAILED_STATUSES.has(status)) {
372
- return "AUTH_FAILED";
373
- }
374
- if (status === TIMEOUT_STATUS) {
375
- return "TIMEOUT";
376
- }
377
- const className = readClassName(error);
378
- if (className !== void 0) {
379
- if (RATE_LIMITED_CLASS_NAMES.has(className)) {
380
- return "RATE_LIMITED";
381
- }
382
- if (AUTH_FAILED_CLASS_NAMES.has(className)) {
383
- return "AUTH_FAILED";
384
- }
385
- if (TIMEOUT_CLASS_NAMES.has(className)) {
386
- return "TIMEOUT";
367
+ function hasRunawayRepetition(text) {
368
+ const scanLength = Math.min(text.length, MAX_SCAN_LENGTH);
369
+ const coverage = scanLength * REPEAT_COVERAGE_FRACTION;
370
+ const maxUnit = Math.min(
371
+ MAX_REPEAT_UNIT_LENGTH,
372
+ Math.floor(scanLength / MIN_CONSECUTIVE_REPEATS)
373
+ );
374
+ for (let unit = 1; unit <= maxUnit; unit++) {
375
+ if (hasDominantRun(text, scanLength, unit, coverage)) {
376
+ return true;
387
377
  }
388
378
  }
389
- return "PROVIDER_ERROR";
379
+ return false;
390
380
  }
391
- function isAbortShapedError(error) {
392
- if (error instanceof Error && error.name === "AbortError") {
393
- return true;
381
+ function assessValueDegeneracy(sourceValue, candidateValue) {
382
+ const source = sourceValue.trim();
383
+ const candidate = candidateValue.trim();
384
+ if (source.length >= MIN_SOURCE_LENGTH && candidate.length >= source.length * MAX_LENGTH_MULTIPLE) {
385
+ return { degenerate: true };
394
386
  }
395
- const className = readClassName(error);
396
- return className !== void 0 && ABORT_ERROR_CLASS_NAMES.has(className);
387
+ return { degenerate: hasRunawayRepetition(candidate) };
397
388
  }
398
- function isAbortError(error, signal) {
399
- if (signal !== void 0) {
400
- return signal.aborted === true && isAbortShapedError(error);
389
+ var LOCALE_TOKEN = "{locale}";
390
+ function localeFilePath(cwd, pattern, locale) {
391
+ return resolve(cwd, pattern.replaceAll(LOCALE_TOKEN, locale));
392
+ }
393
+ function checkBatchIntegrity(inputs, extract, compare) {
394
+ const integrity = /* @__PURE__ */ new Map();
395
+ for (const { key, sourceValue, translatedValue } of inputs) {
396
+ integrity.set(
397
+ key,
398
+ compare !== void 0 ? compare(sourceValue, translatedValue) : checkPlaceholders(extract(sourceValue), extract(translatedValue))
399
+ );
401
400
  }
402
- return isAbortShapedError(error);
401
+ return integrity;
403
402
  }
404
403
  var REDACTED = "[REDACTED]";
405
404
  var KEY_PATTERNS = [
@@ -437,38 +436,6 @@ var ProviderError = class extends Error {
437
436
  this.code = code;
438
437
  }
439
438
  };
440
- var PROVIDER_CALL_FAILED_MESSAGE = "The translation provider request failed.";
441
- var RATE_LIMITED_MESSAGE = "The translation provider rate-limited this request.";
442
- var TIMEOUT_MESSAGE = "The translation provider request timed out.";
443
- var AUTH_FAILED_MESSAGE = "The translation provider rejected the request credentials.";
444
- var MESSAGE_BY_CODE = {
445
- RATE_LIMITED: RATE_LIMITED_MESSAGE,
446
- TIMEOUT: TIMEOUT_MESSAGE,
447
- AUTH_FAILED: AUTH_FAILED_MESSAGE,
448
- PROVIDER_ERROR: PROVIDER_CALL_FAILED_MESSAGE
449
- };
450
- async function guardProviderCall(call, signal) {
451
- signal?.throwIfAborted();
452
- try {
453
- return await call();
454
- } catch (error) {
455
- if (isAbortError(error, signal)) {
456
- throw error;
457
- }
458
- const code = classifyProviderError(error);
459
- throw new ProviderError(code, MESSAGE_BY_CODE[code]);
460
- }
461
- }
462
- function checkBatchIntegrity(inputs, extract, compare) {
463
- const integrity = /* @__PURE__ */ new Map();
464
- for (const { key, sourceValue, translatedValue } of inputs) {
465
- integrity.set(
466
- key,
467
- compare !== void 0 ? compare(sourceValue, translatedValue) : checkPlaceholders(extract(sourceValue), extract(translatedValue))
468
- );
469
- }
470
- return integrity;
471
- }
472
439
  var requestDataSchema = z.object({
473
440
  sourceLocale: z.string().min(1),
474
441
  targetLocale: z.string().min(1),
@@ -711,6 +678,121 @@ function assertNotTruncated(truncated) {
711
678
  throw new ProviderError("OUTPUT_TRUNCATED", OUTPUT_TRUNCATED_MESSAGE);
712
679
  }
713
680
  }
681
+ var RATE_LIMITED_STATUS = 429;
682
+ var TIMEOUT_STATUS = 408;
683
+ var AUTH_FAILED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
684
+ var RATE_LIMITED_CLASS_NAMES = /* @__PURE__ */ new Set([
685
+ "RateLimitError",
686
+ "TooManyRequestsError"
687
+ ]);
688
+ var AUTH_FAILED_CLASS_NAMES = /* @__PURE__ */ new Set([
689
+ "AuthenticationError",
690
+ "PermissionDeniedError",
691
+ "AuthorizationError"
692
+ ]);
693
+ var TIMEOUT_CLASS_NAMES = /* @__PURE__ */ new Set([
694
+ "APIConnectionTimeoutError",
695
+ "ConnectionError"
696
+ ]);
697
+ var ABORT_ERROR_CLASS_NAMES = /* @__PURE__ */ new Set(["APIUserAbortError"]);
698
+ function getErrorStatus(error) {
699
+ if (typeof error !== "object" || error === null || !("status" in error)) {
700
+ return void 0;
701
+ }
702
+ const status = error.status;
703
+ return typeof status === "number" ? status : void 0;
704
+ }
705
+ function readClassName(error) {
706
+ if (typeof error !== "object" || error === null) {
707
+ return void 0;
708
+ }
709
+ return error.constructor?.name;
710
+ }
711
+ function classifyProviderError(error) {
712
+ const status = getErrorStatus(error);
713
+ if (status === RATE_LIMITED_STATUS) {
714
+ return "RATE_LIMITED";
715
+ }
716
+ if (status !== void 0 && AUTH_FAILED_STATUSES.has(status)) {
717
+ return "AUTH_FAILED";
718
+ }
719
+ if (status === TIMEOUT_STATUS) {
720
+ return "TIMEOUT";
721
+ }
722
+ const className = readClassName(error);
723
+ if (className !== void 0) {
724
+ if (RATE_LIMITED_CLASS_NAMES.has(className)) {
725
+ return "RATE_LIMITED";
726
+ }
727
+ if (AUTH_FAILED_CLASS_NAMES.has(className)) {
728
+ return "AUTH_FAILED";
729
+ }
730
+ if (TIMEOUT_CLASS_NAMES.has(className)) {
731
+ return "TIMEOUT";
732
+ }
733
+ }
734
+ return "PROVIDER_ERROR";
735
+ }
736
+ function isAbortShapedError(error) {
737
+ if (error instanceof Error && error.name === "AbortError") {
738
+ return true;
739
+ }
740
+ const className = readClassName(error);
741
+ return className !== void 0 && ABORT_ERROR_CLASS_NAMES.has(className);
742
+ }
743
+ function isAbortError(error, signal) {
744
+ if (signal !== void 0) {
745
+ return signal.aborted === true && isAbortShapedError(error);
746
+ }
747
+ return isAbortShapedError(error);
748
+ }
749
+ var PROVIDER_CALL_FAILED_MESSAGE = "The translation provider request failed.";
750
+ var RATE_LIMITED_MESSAGE = "The translation provider rate-limited this request.";
751
+ var TIMEOUT_MESSAGE = "The translation provider request timed out.";
752
+ var AUTH_FAILED_MESSAGE = "The translation provider rejected the request credentials.";
753
+ var MESSAGE_BY_CODE = {
754
+ RATE_LIMITED: RATE_LIMITED_MESSAGE,
755
+ TIMEOUT: TIMEOUT_MESSAGE,
756
+ AUTH_FAILED: AUTH_FAILED_MESSAGE,
757
+ PROVIDER_ERROR: PROVIDER_CALL_FAILED_MESSAGE
758
+ };
759
+ async function guardProviderCall(call, signal) {
760
+ signal?.throwIfAborted();
761
+ try {
762
+ return await call();
763
+ } catch (error) {
764
+ if (isAbortError(error, signal)) {
765
+ throw error;
766
+ }
767
+ const code = classifyProviderError(error);
768
+ throw new ProviderError(code, MESSAGE_BY_CODE[code]);
769
+ }
770
+ }
771
+ var DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
772
+ function requestTimedOutMessage(timeoutMs) {
773
+ return `The translation provider request exceeded the ${timeoutMs} ms request timeout.`;
774
+ }
775
+ function combineSignals(caller, timeout) {
776
+ return caller === void 0 ? timeout : AbortSignal.any([caller, timeout]);
777
+ }
778
+ async function withRequestTimeout(timeoutMs, callerSignal, call) {
779
+ const timeoutController = new AbortController();
780
+ const signal = combineSignals(callerSignal, timeoutController.signal);
781
+ let timer;
782
+ const timedOut = new Promise((_, reject) => {
783
+ timer = setTimeout(() => {
784
+ timeoutController.abort();
785
+ reject(new ProviderError("TIMEOUT", requestTimedOutMessage(timeoutMs)));
786
+ }, timeoutMs);
787
+ });
788
+ try {
789
+ return await Promise.race([guardProviderCall(() => call(signal), signal), timedOut]);
790
+ } finally {
791
+ if (timer !== void 0) {
792
+ clearTimeout(timer);
793
+ }
794
+ }
795
+ }
714
796
  var PROVIDER_ENV = {
715
797
  anthropic: "ANTHROPIC_API_KEY",
716
798
  openai: "OPENAI_API_KEY",
@@ -765,7 +847,13 @@ function createDefaultClient() {
765
847
  }
766
848
  var anthropicConfigSchema = z.object({
767
849
  model: z.string().min(1),
768
- maxTokens: z.number().int().positive()
850
+ maxTokens: z.number().int().positive(),
851
+ /**
852
+ * Optional verbatra-imposed per-request timeout in milliseconds. A positive integer; when absent,
853
+ * the shared default request timeout applies. Bounds each outbound request so a hung-but-alive
854
+ * server cannot hold a locale's write lock open forever.
855
+ */
856
+ requestTimeoutMs: z.number().int().positive().optional()
769
857
  });
770
858
  var SUBMIT_TOOL_NAME = "submit_translations";
771
859
  var SYSTEM_RULES = [
@@ -825,10 +913,11 @@ function createAnthropicProvider(config, deps = {}) {
825
913
  };
826
914
  }
827
915
  function createMechanism(client, config) {
916
+ const timeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
828
917
  return {
829
918
  translate: async ({ payloadJson, signal }) => {
830
919
  const body = buildRequest(config, payloadJson);
831
- const message = await callClient(client, body, signal);
920
+ const message = await callClient(client, body, timeoutMs, signal);
832
921
  assertNotTruncated(message.stop_reason === "max_tokens");
833
922
  const raw = requireToolInput(message.content);
834
923
  const usage = toUsage(message.usage);
@@ -836,10 +925,11 @@ function createMechanism(client, config) {
836
925
  }
837
926
  };
838
927
  }
839
- function callClient(client, body, signal) {
840
- return guardProviderCall(
841
- () => client.messages.create(body, signal !== void 0 ? { signal } : void 0),
842
- signal
928
+ function callClient(client, body, timeoutMs, signal) {
929
+ return withRequestTimeout(
930
+ timeoutMs,
931
+ signal,
932
+ (requestSignal) => client.messages.create(body, { signal: requestSignal })
843
933
  );
844
934
  }
845
935
  function toUsage(usage) {
@@ -853,7 +943,14 @@ function toUsage(usage) {
853
943
  return { inputTokens: input_tokens, outputTokens: output_tokens };
854
944
  }
855
945
  var deepLConfigSchema = z.object({
856
- glossaryId: z.string().min(1).optional()
946
+ glossaryId: z.string().min(1).optional(),
947
+ /**
948
+ * Optional verbatra-imposed per-request timeout in milliseconds. A positive integer; when absent,
949
+ * the shared default request timeout applies. Bounds each outbound request so a hung-but-alive
950
+ * server cannot hold a locale's write lock open forever. deepl-node cannot cancel an in-flight
951
+ * request, so on timeout verbatra's await is released but the underlying request is left to settle.
952
+ */
953
+ requestTimeoutMs: z.number().int().positive().optional()
857
954
  });
858
955
  var DEEPL_LOGGER = "deepl";
859
956
  function resolveDeeplLoglevel(requireFn = createRequire(import.meta.url)) {
@@ -1024,6 +1121,7 @@ async function translate(bundle, config, request) {
1024
1121
  options,
1025
1122
  request.extractPlaceholders,
1026
1123
  request.comparePlaceholders,
1124
+ config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
1027
1125
  request.signal
1028
1126
  );
1029
1127
  if (unprotectable.length > 0) {
@@ -1058,7 +1156,7 @@ function buildReviewFlags2(protectable, data, values, integrity, glossary) {
1058
1156
  }
1059
1157
  return reviewFlags;
1060
1158
  }
1061
- async function translateProtectable(client, data, protectable, options, extract, compare, signal) {
1159
+ async function translateProtectable(client, data, protectable, options, extract, compare, timeoutMs, signal) {
1062
1160
  if (protectable.length === 0) {
1063
1161
  return { values: /* @__PURE__ */ new Map(), integrity: /* @__PURE__ */ new Map() };
1064
1162
  }
@@ -1069,29 +1167,45 @@ async function translateProtectable(client, data, protectable, options, extract,
1069
1167
  data.sourceLocale,
1070
1168
  data.targetLocale,
1071
1169
  options,
1170
+ timeoutMs,
1072
1171
  signal
1073
1172
  );
1074
1173
  const { values, integrityInputs } = zipResults(protectable, results);
1075
1174
  const integrity = checkBatchIntegrity(integrityInputs, extract, compare);
1076
1175
  return { values, integrity };
1077
1176
  }
1078
- function callClient2(client, texts, sourceLang, targetLang, options, signal) {
1079
- return guardProviderCall(
1080
- () => client.translateText(texts, sourceLang, targetLang, options),
1081
- signal
1177
+ function callClient2(client, texts, sourceLang, targetLang, options, timeoutMs, signal) {
1178
+ return withRequestTimeout(
1179
+ timeoutMs,
1180
+ signal,
1181
+ () => client.translateText(texts, sourceLang, targetLang, options)
1082
1182
  );
1083
1183
  }
1084
- async function callClientChunked(client, texts, sourceLang, targetLang, options, signal) {
1184
+ async function callClientChunked(client, texts, sourceLang, targetLang, options, timeoutMs, signal) {
1085
1185
  const results = [];
1086
1186
  for (const chunk2 of chunkTextsForDeepL(texts)) {
1087
- const chunkResults = await callClient2(client, chunk2, sourceLang, targetLang, options, signal);
1187
+ const chunkResults = await callClient2(
1188
+ client,
1189
+ chunk2,
1190
+ sourceLang,
1191
+ targetLang,
1192
+ options,
1193
+ timeoutMs,
1194
+ signal
1195
+ );
1088
1196
  results.push(...chunkResults);
1089
1197
  }
1090
1198
  return results;
1091
1199
  }
1092
1200
  var geminiConfigSchema = z.object({
1093
1201
  model: z.string().min(1),
1094
- maxOutputTokens: z.number().int().positive()
1202
+ maxOutputTokens: z.number().int().positive(),
1203
+ /**
1204
+ * Optional verbatra-imposed per-request timeout in milliseconds. A positive integer; when absent,
1205
+ * the shared default request timeout applies. Bounds each outbound request so a hung-but-alive
1206
+ * server cannot hold a locale's write lock open forever.
1207
+ */
1208
+ requestTimeoutMs: z.number().int().positive().optional()
1095
1209
  });
1096
1210
  var DEFAULT_GEMINI_RETRY = { attempts: 3, baseDelayMs: 250 };
1097
1211
  function isRetryableStatus(error) {
@@ -1281,20 +1395,27 @@ function createGeminiProvider(config, deps = {}) {
1281
1395
  };
1282
1396
  }
1283
1397
  function createMechanism2(client, config) {
1398
+ const timeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
1284
1399
  return {
1285
1400
  translate: async ({ payloadJson, signal }) => {
1286
- const request = buildGeminiRequest(config, payloadJson, signal);
1287
- const response = await callClient3(client, request, signal);
1401
+ const response = await withRequestTimeout(
1402
+ timeoutMs,
1403
+ signal,
1404
+ (requestSignal) => client.models.generateContent(buildGeminiRequest(config, payloadJson, requestSignal))
1405
+ );
1288
1406
  return extractGeminiResult(response);
1289
1407
  }
1290
1408
  };
1291
1409
  }
1292
- function callClient3(client, request, signal) {
1293
- return guardProviderCall(() => client.models.generateContent(request), signal);
1294
- }
1295
1410
  var openAiConfigSchema = z.object({
1296
1411
  model: z.string().min(1),
1297
- maxOutputTokens: z.number().int().positive()
1412
+ maxOutputTokens: z.number().int().positive(),
1413
+ /**
1414
+ * Optional verbatra-imposed per-request timeout in milliseconds. A positive integer; when absent,
1415
+ * the shared default request timeout applies. Bounds each outbound request so a hung-but-alive
1416
+ * server cannot hold a locale's write lock open forever.
1417
+ */
1418
+ requestTimeoutMs: z.number().int().positive().optional()
1298
1419
  });
1299
1420
  function createDefaultClient4() {
1300
1421
  const sdk = new OpenAI({ apiKey: requireOpenAiKey(), logLevel: "off" });
@@ -1449,18 +1570,20 @@ function createOpenAiProvider(config, deps = {}) {
1449
1570
  };
1450
1571
  }
1451
1572
  function createMechanism3(client, config) {
1573
+ const timeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
1452
1574
  return {
1453
1575
  translate: async ({ payloadJson, signal }) => {
1454
1576
  const body = buildOpenAiRequest(config, payloadJson);
1455
- const completion = await callClient4(client, body, signal);
1577
+ const completion = await callClient3(client, body, timeoutMs, signal);
1456
1578
  return extractOpenAiResult(completion);
1457
1579
  }
1458
1580
  };
1459
1581
  }
1460
- function callClient4(client, body, signal) {
1461
- return guardProviderCall(
1462
- () => client.chat.completions.create(body, signal !== void 0 ? { signal } : void 0),
1463
- signal
1582
+ function callClient3(client, body, timeoutMs, signal) {
1583
+ return withRequestTimeout(
1584
+ timeoutMs,
1585
+ signal,
1586
+ (requestSignal) => client.chat.completions.create(body, { signal: requestSignal })
1464
1587
  );
1465
1588
  }
1466
1589
  var HOSTED_PROVIDER_ENV_VARS = new Set(
@@ -1483,7 +1606,13 @@ var openAiCompatibleConfigSchema = z.object({
1483
1606
  maxOutputTokens: z.number().int().positive(),
1484
1607
  apiKeyEnvVar: z.string().min(1).refine(isNotHostedProviderEnvVar, {
1485
1608
  message: "apiKeyEnvVar must not name a hosted provider's environment variable."
1486
- }).optional()
1609
+ }).optional(),
1610
+ /**
1611
+ * Optional verbatra-imposed per-request timeout in milliseconds. A positive integer; when absent,
1612
+ * the shared default request timeout applies. Bounds each outbound request so a hung-but-alive local
1613
+ * server (the reported stuck-LM-Studio case) cannot hold a locale's write lock open forever.
1614
+ */
1615
+ requestTimeoutMs: z.number().int().positive().optional()
1487
1616
  });
1488
1617
  function createDefaultClient5(config) {
1489
1618
  const sdk = new OpenAI({
@@ -1515,18 +1644,20 @@ function createOpenAiCompatibleProvider(config, deps = {}) {
1515
1644
  };
1516
1645
  }
1517
1646
  function createMechanism4(client, config) {
1647
+ const timeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
1518
1648
  return {
1519
1649
  translate: async ({ payloadJson, signal }) => {
1520
1650
  const body = buildOpenAiRequest(config, payloadJson, "strict-schema", "max_tokens");
1521
- const completion = await callClient5(client, body, signal);
1651
+ const completion = await callClient4(client, body, timeoutMs, signal);
1522
1652
  return extractOpenAiResult(completion, true);
1523
1653
  }
1524
1654
  };
1525
1655
  }
1526
- function callClient5(client, body, signal) {
1527
- return guardProviderCall(
1528
- () => client.chat.completions.create(body, signal !== void 0 ? { signal } : void 0),
1529
- signal
1656
+ function callClient4(client, body, timeoutMs, signal) {
1657
+ return withRequestTimeout(
1658
+ timeoutMs,
1659
+ signal,
1660
+ (requestSignal) => client.chat.completions.create(body, { signal: requestSignal })
1530
1661
  );
1531
1662
  }
1532
1663
  var SCAFFOLD_MODELS = {
@@ -1736,6 +1867,8 @@ var LOCAL_DIR_NAME = ".verbatra-local";
1736
1867
  var LOCK_FILE_GUARD_STEM = "_lockfile";
1737
1868
  var DEFAULT_POLL_INTERVAL_MS = 100;
1738
1869
  var DEFAULT_ACQUIRE_TIMEOUT_MS = 10 * 6e4;
1870
+ var WAIT_NOTICE_INTERVAL_MS = 1e3;
1871
+ var MAX_LOCK_PAYLOAD_BYTES = 64 * 1024;
1739
1872
  function lockPath(cwd, stem) {
1740
1873
  return resolve(cwd, LOCAL_DIR_NAME, "locks", `${stem}.lock`);
1741
1874
  }
@@ -1753,7 +1886,47 @@ function sleep(ms) {
1753
1886
  function lockPayload() {
1754
1887
  return JSON.stringify({ pid: process.pid, acquiredAt: (/* @__PURE__ */ new Date()).toISOString() });
1755
1888
  }
1756
- async function acquireLock(path, fs, pollIntervalMs, deadline) {
1889
+ function parseHolder(read) {
1890
+ if (read.kind !== "ok") {
1891
+ return void 0;
1892
+ }
1893
+ let parsed;
1894
+ try {
1895
+ parsed = JSON.parse(read.content);
1896
+ } catch {
1897
+ return void 0;
1898
+ }
1899
+ if (typeof parsed !== "object" || parsed === null) {
1900
+ return void 0;
1901
+ }
1902
+ const record = parsed;
1903
+ const holder = {};
1904
+ if (typeof record.pid === "number") {
1905
+ holder.pid = record.pid;
1906
+ }
1907
+ if (typeof record.acquiredAt === "string") {
1908
+ holder.acquiredAt = record.acquiredAt;
1909
+ }
1910
+ return holder;
1911
+ }
1912
+ function makeWaitNotifier(path, fs, onWait, start) {
1913
+ let holder;
1914
+ let holderRead = false;
1915
+ let lastEmit;
1916
+ return async () => {
1917
+ if (!holderRead) {
1918
+ holderRead = true;
1919
+ holder = parseHolder(await fs.readFileBounded(path, MAX_LOCK_PAYLOAD_BYTES));
1920
+ }
1921
+ const elapsedMs = Date.now() - start;
1922
+ if (lastEmit !== void 0 && elapsedMs - lastEmit < WAIT_NOTICE_INTERVAL_MS) {
1923
+ return;
1924
+ }
1925
+ lastEmit = elapsedMs;
1926
+ onWait({ lockPath: path, elapsedMs, ...holder !== void 0 ? { holder } : {} });
1927
+ };
1928
+ }
1929
+ async function acquireLock(path, fs, pollIntervalMs, deadline, notify) {
1757
1930
  for (; ; ) {
1758
1931
  if (await fs.createExclusive(path, lockPayload())) {
1759
1932
  return;
@@ -1764,6 +1937,9 @@ async function acquireLock(path, fs, pollIntervalMs, deadline) {
1764
1937
  `Could not acquire the write lock at ${path}: another process may be holding it. If no verbatra process is currently running, this lock file was likely left behind by one that was killed; delete it and retry.`
1765
1938
  );
1766
1939
  }
1940
+ if (notify !== void 0) {
1941
+ await notify();
1942
+ }
1767
1943
  const jitter = Math.random() * pollIntervalMs;
1768
1944
  await sleep(pollIntervalMs + jitter);
1769
1945
  }
@@ -1771,7 +1947,9 @@ async function acquireLock(path, fs, pollIntervalMs, deadline) {
1771
1947
  async function withFileLock(path, fs, fn, options) {
1772
1948
  const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1773
1949
  const acquireTimeoutMs = options.acquireTimeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS;
1774
- await acquireLock(path, fs, pollIntervalMs, Date.now() + acquireTimeoutMs);
1950
+ const start = Date.now();
1951
+ const notify = options.onWait !== void 0 ? makeWaitNotifier(path, fs, options.onWait, start) : void 0;
1952
+ await acquireLock(path, fs, pollIntervalMs, start + acquireTimeoutMs, notify);
1775
1953
  try {
1776
1954
  return await fn();
1777
1955
  } finally {
@@ -3456,6 +3634,9 @@ function gateCandidateValue(sourceEntry, candidateValue, adapter) {
3456
3634
  if (!adapter.validateMessage(candidateValue)) {
3457
3635
  return { accepted: false, reason: "icu" };
3458
3636
  }
3637
+ if (assessValueDegeneracy(sourceEntry.value, candidateValue).degenerate) {
3638
+ return { accepted: false, reason: "degenerate" };
3639
+ }
3459
3640
  return { accepted: true };
3460
3641
  }
3461
3642
 
@@ -3743,7 +3924,7 @@ var runBudgetSchema = z.object({
3743
3924
  });
3744
3925
  var runStatusLocaleSchema = z.object({
3745
3926
  locale: z.string(),
3746
- status: z.enum(["succeeded", "failed"]),
3927
+ status: z.enum(["succeeded", "partial", "failed"]),
3747
3928
  needsReview: z.array(needsReviewEntrySchema),
3748
3929
  usage: usageSummarySchema.optional()
3749
3930
  });
@@ -3888,10 +4069,21 @@ function failureSummary(locale, error) {
3888
4069
  error: describeError(error)
3889
4070
  };
3890
4071
  }
4072
+ function deriveLocaleStatus(parts) {
4073
+ const withheld = parts.integrityMismatches.length > 0 || parts.providerFailures.length > 0 || parts.budgetWithheld.length > 0;
4074
+ if (!withheld) {
4075
+ return "succeeded";
4076
+ }
4077
+ const accepted = parts.translated.length > 0 || parts.generated.length > 0;
4078
+ return accepted ? "partial" : "failed";
4079
+ }
3891
4080
  function partition(locales) {
3892
- const succeeded = locales.filter((s) => s.status === "succeeded").map((s) => s.locale);
3893
- const failed = locales.filter((s) => s.status === "failed").map((s) => s.locale);
3894
- return { succeeded, failed };
4081
+ const namesWith = (status) => locales.filter((s) => s.status === status).map((s) => s.locale);
4082
+ return {
4083
+ succeeded: namesWith("succeeded"),
4084
+ partial: namesWith("partial"),
4085
+ failed: namesWith("failed")
4086
+ };
3895
4087
  }
3896
4088
 
3897
4089
  // src/flow/batching.ts
@@ -4372,7 +4564,7 @@ function pluralNoticeFor(params, merged) {
4372
4564
  function baseSummary(parts) {
4373
4565
  return {
4374
4566
  locale: parts.locale,
4375
- status: "succeeded",
4567
+ status: deriveLocaleStatus(parts),
4376
4568
  translated: parts.translated,
4377
4569
  unchanged: parts.unchanged,
4378
4570
  orphaned: parts.orphaned,
@@ -4431,10 +4623,16 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
4431
4623
  try {
4432
4624
  result = await provider.translateBatch(buildRequest3(params, batch));
4433
4625
  } catch (error) {
4434
- for (const entry of batch) {
4435
- providerFailures.push(entry.key);
4436
- }
4437
- return { notices: [subBatchFailedNotice(batch.length, error)], usage: void 0 };
4626
+ return handleSubBatchFailure(
4627
+ error,
4628
+ provider,
4629
+ params,
4630
+ batch,
4631
+ accepted,
4632
+ integrityMismatches,
4633
+ providerFailures,
4634
+ reviewFlags
4635
+ );
4438
4636
  }
4439
4637
  for (const entry of batch) {
4440
4638
  foldEntryResult(entry, result, params.adapter, accepted, integrityMismatches, providerFailures);
@@ -4446,6 +4644,44 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
4446
4644
  }
4447
4645
  return { notices: readNotices(result), usage: result.usage };
4448
4646
  }
4647
+ function isOutputTruncated(error) {
4648
+ return error instanceof ProviderError && error.code === "OUTPUT_TRUNCATED";
4649
+ }
4650
+ async function handleSubBatchFailure(error, provider, params, batch, accepted, integrityMismatches, providerFailures, reviewFlags) {
4651
+ if (isOutputTruncated(error) && batch.length > 1) {
4652
+ return retryTruncatedSplit(
4653
+ provider,
4654
+ params,
4655
+ batch,
4656
+ accepted,
4657
+ integrityMismatches,
4658
+ providerFailures,
4659
+ reviewFlags
4660
+ );
4661
+ }
4662
+ for (const entry of batch) {
4663
+ providerFailures.push(entry.key);
4664
+ }
4665
+ return { notices: [subBatchFailedNotice(batch.length, error)], usage: void 0 };
4666
+ }
4667
+ async function retryTruncatedSplit(provider, params, batch, accepted, integrityMismatches, providerFailures, reviewFlags) {
4668
+ const notices = [];
4669
+ let usage;
4670
+ for (const half of chunk(batch, Math.ceil(batch.length / 2))) {
4671
+ const sub = await runSubBatch(
4672
+ provider,
4673
+ params,
4674
+ half,
4675
+ accepted,
4676
+ integrityMismatches,
4677
+ providerFailures,
4678
+ reviewFlags
4679
+ );
4680
+ notices.push(...sub.notices);
4681
+ usage = combineUsage(usage, sub.usage);
4682
+ }
4683
+ return { notices, usage };
4684
+ }
4449
4685
  function foldEntryResult(entry, result, adapter, accepted, integrityMismatches, providerFailures) {
4450
4686
  const value = result.values.get(entry.key);
4451
4687
  if (value === void 0) {
@@ -4529,16 +4765,26 @@ async function runDryLocale(context, targetLocale, lock) {
4529
4765
  return (await runLocale(params)).summary;
4530
4766
  }
4531
4767
  async function runLiveLocale(context, targetLocale) {
4532
- return withLocaleWriteLock(context.cwd, targetLocale, context.fs, async () => {
4533
- const lock = await readLockFile(lockFilePath(context.cwd), context.fs);
4534
- const params = buildLocaleRunParams(context, targetLocale, baselineFor(lock, targetLocale));
4535
- const result = await runLocale(params);
4536
- await updateLockFileLocale(context.cwd, context.fs, targetLocale, {
4537
- mode: "replace",
4538
- entries: result.lockEntries
4539
- });
4540
- return result.summary;
4541
- });
4768
+ const lockOptions = {
4769
+ ...context.onLockWait !== void 0 ? { onWait: context.onLockWait } : {},
4770
+ ...context.lockAcquireTimeoutMs !== void 0 ? { acquireTimeoutMs: context.lockAcquireTimeoutMs } : {}
4771
+ };
4772
+ return withLocaleWriteLock(
4773
+ context.cwd,
4774
+ targetLocale,
4775
+ context.fs,
4776
+ async () => {
4777
+ const lock = await readLockFile(lockFilePath(context.cwd), context.fs);
4778
+ const params = buildLocaleRunParams(context, targetLocale, baselineFor(lock, targetLocale));
4779
+ const result = await runLocale(params);
4780
+ await updateLockFileLocale(context.cwd, context.fs, targetLocale, {
4781
+ mode: "replace",
4782
+ entries: result.lockEntries
4783
+ });
4784
+ return result.summary;
4785
+ },
4786
+ lockOptions
4787
+ );
4542
4788
  }
4543
4789
  async function runOneLocale(targetLocale, run) {
4544
4790
  try {
@@ -4592,10 +4838,12 @@ async function translate2(input, deps = {}) {
4592
4838
  generatePlurals,
4593
4839
  maxBatchSize,
4594
4840
  fs,
4595
- budget
4841
+ budget,
4842
+ ...input.onLockWait !== void 0 ? { onLockWait: input.onLockWait } : {},
4843
+ ...input.lockAcquireTimeoutMs !== void 0 ? { lockAcquireTimeoutMs: input.lockAcquireTimeoutMs } : {}
4596
4844
  };
4597
4845
  const summaries = dryRun ? await runAllLocalesDry(context, config.targetLocales) : await runAllLocalesLive(context, config.targetLocales);
4598
- const { succeeded, failed } = partition(summaries);
4846
+ const { succeeded, partial, failed } = partition(summaries);
4599
4847
  const usage = summaries.reduce(
4600
4848
  (total, summary2) => combineUsage(total, summary2.usage),
4601
4849
  void 0
@@ -4605,6 +4853,7 @@ async function translate2(input, deps = {}) {
4605
4853
  dryRun,
4606
4854
  locales: summaries,
4607
4855
  succeeded,
4856
+ partial,
4608
4857
  failed,
4609
4858
  ...usage !== void 0 ? { usage } : {},
4610
4859
  ...budgetSummary !== void 0 ? { budget: budgetSummary } : {}
@@ -5163,15 +5412,23 @@ function importLocale(params) {
5163
5412
  classifyRows(params, buckets);
5164
5413
  const rowKeys = new Set(params.sheet.rows.map((row) => row.key));
5165
5414
  const invalidIcuSource = [...new Set(params.sourceInvalidIcuKeys)].filter((key) => rowKeys.has(key)).sort();
5415
+ const translated = [...buckets.accepted.keys()].sort();
5416
+ const integrityMismatches = [...buckets.mismatches].sort();
5166
5417
  const summary = {
5167
5418
  locale: params.sheet.locale,
5168
- status: "succeeded",
5169
- translated: [...buckets.accepted.keys()].sort(),
5419
+ status: deriveLocaleStatus({
5420
+ translated,
5421
+ generated: [],
5422
+ integrityMismatches,
5423
+ providerFailures: [],
5424
+ budgetWithheld: []
5425
+ }),
5426
+ translated,
5170
5427
  unchanged: diff2.unchanged,
5171
5428
  orphaned: diff2.orphaned,
5172
5429
  pruned: [],
5173
5430
  invalidIcuSource,
5174
- integrityMismatches: [...buckets.mismatches].sort(),
5431
+ integrityMismatches,
5175
5432
  providerFailures: [],
5176
5433
  budgetWithheld: [],
5177
5434
  generated: [],
@@ -5300,8 +5557,8 @@ async function importWorkbook(input, deps = {}) {
5300
5557
  summaries.push(failureSummary(sheet.locale, error));
5301
5558
  }
5302
5559
  }
5303
- const { succeeded, failed } = partition(summaries);
5304
- return { dryRun, locales: summaries, succeeded, failed };
5560
+ const { succeeded, partial, failed } = partition(summaries);
5561
+ return { dryRun, locales: summaries, succeeded, partial, failed };
5305
5562
  }
5306
5563
 
5307
5564
  // src/lock/load-lock-file.ts
@@ -5359,7 +5616,12 @@ async function watch(input, deps = {}) {
5359
5616
  );
5360
5617
  }
5361
5618
  const runTranslate = deps.runTranslate ?? defaultRunTranslate(deps);
5362
- const runInput = { config: input.config, cwd };
5619
+ const runInput = {
5620
+ config: input.config,
5621
+ cwd,
5622
+ ...input.onLockWait !== void 0 ? { onLockWait: input.onLockWait } : {},
5623
+ ...input.lockAcquireTimeoutMs !== void 0 ? { lockAcquireTimeoutMs: input.lockAcquireTimeoutMs } : {}
5624
+ };
5363
5625
  let state = "idle";
5364
5626
  let pending = false;
5365
5627
  let stopped = false;