@verbatra/sdk 0.5.0-next.0 → 0.5.0-next.2

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
@@ -341,6 +341,85 @@ var LOCALE_TOKEN = "{locale}";
341
341
  function localeFilePath(cwd, pattern, locale) {
342
342
  return path.resolve(cwd, pattern.replaceAll(LOCALE_TOKEN, locale));
343
343
  }
344
+ var RATE_LIMITED_STATUS = 429;
345
+ var TIMEOUT_STATUS = 408;
346
+ var AUTH_FAILED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
347
+ var RATE_LIMITED_CLASS_NAMES = /* @__PURE__ */ new Set([
348
+ "RateLimitError",
349
+ // openai, @anthropic-ai/sdk
350
+ "TooManyRequestsError"
351
+ // deepl-node
352
+ ]);
353
+ var AUTH_FAILED_CLASS_NAMES = /* @__PURE__ */ new Set([
354
+ "AuthenticationError",
355
+ // openai, @anthropic-ai/sdk (401)
356
+ "PermissionDeniedError",
357
+ // openai, @anthropic-ai/sdk (403)
358
+ "AuthorizationError"
359
+ // deepl-node
360
+ ]);
361
+ var TIMEOUT_CLASS_NAMES = /* @__PURE__ */ new Set([
362
+ "APIConnectionTimeoutError",
363
+ // openai, @anthropic-ai/sdk
364
+ // deepl-node has no distinct timeout class; ConnectionError is its only network-failure
365
+ // class, so it is folded into TIMEOUT rather than the PROVIDER_ERROR fallback.
366
+ "ConnectionError"
367
+ ]);
368
+ var ABORT_ERROR_CLASS_NAMES = /* @__PURE__ */ new Set([
369
+ "APIUserAbortError"
370
+ // openai, @anthropic-ai/sdk
371
+ ]);
372
+ function getErrorStatus(error) {
373
+ if (typeof error !== "object" || error === null || !("status" in error)) {
374
+ return void 0;
375
+ }
376
+ const status = error.status;
377
+ return typeof status === "number" ? status : void 0;
378
+ }
379
+ function readClassName(error) {
380
+ if (typeof error !== "object" || error === null) {
381
+ return void 0;
382
+ }
383
+ return error.constructor?.name;
384
+ }
385
+ function classifyProviderError(error) {
386
+ const status = getErrorStatus(error);
387
+ if (status === RATE_LIMITED_STATUS) {
388
+ return "RATE_LIMITED";
389
+ }
390
+ if (status !== void 0 && AUTH_FAILED_STATUSES.has(status)) {
391
+ return "AUTH_FAILED";
392
+ }
393
+ if (status === TIMEOUT_STATUS) {
394
+ return "TIMEOUT";
395
+ }
396
+ const className = readClassName(error);
397
+ if (className !== void 0) {
398
+ if (RATE_LIMITED_CLASS_NAMES.has(className)) {
399
+ return "RATE_LIMITED";
400
+ }
401
+ if (AUTH_FAILED_CLASS_NAMES.has(className)) {
402
+ return "AUTH_FAILED";
403
+ }
404
+ if (TIMEOUT_CLASS_NAMES.has(className)) {
405
+ return "TIMEOUT";
406
+ }
407
+ }
408
+ return "PROVIDER_ERROR";
409
+ }
410
+ function isAbortShapedError(error) {
411
+ if (error instanceof Error && error.name === "AbortError") {
412
+ return true;
413
+ }
414
+ const className = readClassName(error);
415
+ return className !== void 0 && ABORT_ERROR_CLASS_NAMES.has(className);
416
+ }
417
+ function isAbortError(error, signal) {
418
+ if (signal !== void 0) {
419
+ return signal.aborted === true && isAbortShapedError(error);
420
+ }
421
+ return isAbortShapedError(error);
422
+ }
344
423
  var REDACTED = "[REDACTED]";
345
424
  var KEY_PATTERNS = [
346
425
  // The `\b` anchors `sk-` to a word start so hyphenated words like "risk-" or "task-" pass through.
@@ -375,17 +454,34 @@ var ProviderError = class extends Error {
375
454
  }
376
455
  };
377
456
  var PROVIDER_CALL_FAILED_MESSAGE = "The translation provider request failed.";
378
- async function guardProviderCall(call) {
457
+ var RATE_LIMITED_MESSAGE = "The translation provider rate-limited this request.";
458
+ var TIMEOUT_MESSAGE = "The translation provider request timed out.";
459
+ var AUTH_FAILED_MESSAGE = "The translation provider rejected the request credentials.";
460
+ var MESSAGE_BY_CODE = {
461
+ RATE_LIMITED: RATE_LIMITED_MESSAGE,
462
+ TIMEOUT: TIMEOUT_MESSAGE,
463
+ AUTH_FAILED: AUTH_FAILED_MESSAGE,
464
+ PROVIDER_ERROR: PROVIDER_CALL_FAILED_MESSAGE
465
+ };
466
+ async function guardProviderCall(call, signal) {
467
+ signal?.throwIfAborted();
379
468
  try {
380
469
  return await call();
381
- } catch {
382
- throw new ProviderError("PROVIDER_ERROR", PROVIDER_CALL_FAILED_MESSAGE);
470
+ } catch (error) {
471
+ if (isAbortError(error, signal)) {
472
+ throw error;
473
+ }
474
+ const code = classifyProviderError(error);
475
+ throw new ProviderError(code, MESSAGE_BY_CODE[code]);
383
476
  }
384
477
  }
385
- function checkBatchIntegrity(inputs, extract) {
478
+ function checkBatchIntegrity(inputs, extract, compare) {
386
479
  const integrity = /* @__PURE__ */ new Map();
387
- for (const { key, sourcePlaceholders, translatedValue } of inputs) {
388
- integrity.set(key, checkPlaceholders(sourcePlaceholders, extract(translatedValue)));
480
+ for (const { key, sourceValue, translatedValue } of inputs) {
481
+ integrity.set(
482
+ key,
483
+ compare !== void 0 ? compare(sourceValue, translatedValue) : checkPlaceholders(extract(sourceValue), extract(translatedValue))
484
+ );
389
485
  }
390
486
  return integrity;
391
487
  }
@@ -407,16 +503,14 @@ function validateRequest(request) {
407
503
  return parsed.data;
408
504
  }
409
505
  function toIntegrityInputs(entries, values) {
410
- return entries.map((entry) => {
506
+ const inputs = [];
507
+ for (const entry of entries) {
411
508
  const translatedValue = values.get(entry.key);
412
- if (translatedValue === void 0) {
413
- throw new ProviderError(
414
- "INVALID_RESPONSE",
415
- "The provider response is missing one or more keys."
416
- );
509
+ if (translatedValue !== void 0) {
510
+ inputs.push({ key: entry.key, sourceValue: entry.value, translatedValue });
417
511
  }
418
- return { key: entry.key, sourcePlaceholders: entry.placeholders, translatedValue };
419
- });
512
+ }
513
+ return inputs;
420
514
  }
421
515
  function toItem(entry) {
422
516
  return {
@@ -448,25 +542,29 @@ function deriveJsonSchema(schema) {
448
542
  }
449
543
  return result;
450
544
  }
451
- function reconcile(translations, requestedKeys) {
452
- const requested = new Set(requestedKeys);
453
- const values = /* @__PURE__ */ new Map();
545
+ function partitionTranslations(translations, requested) {
546
+ const firstSeen = /* @__PURE__ */ new Map();
547
+ const duplicated = /* @__PURE__ */ new Set();
454
548
  for (const { key, value } of translations) {
455
- if (!requested.has(key) || values.has(key)) {
456
- throw new ProviderError(
457
- "INVALID_RESPONSE",
458
- "The provider returned an unexpected or duplicate key."
459
- );
549
+ if (!requested.has(key)) {
550
+ throw new ProviderError("INVALID_RESPONSE", "The provider returned an unexpected key.");
551
+ }
552
+ if (firstSeen.has(key)) {
553
+ duplicated.add(key);
554
+ continue;
460
555
  }
461
- values.set(key, value);
556
+ firstSeen.set(key, value);
462
557
  }
463
- if (values.size !== requested.size) {
464
- throw new ProviderError(
465
- "INVALID_RESPONSE",
466
- "The provider response is missing one or more keys."
467
- );
558
+ return { firstSeen, duplicated };
559
+ }
560
+ function reconcile(translations, requestedKeys) {
561
+ const requested = new Set(requestedKeys);
562
+ const { firstSeen, duplicated } = partitionTranslations(translations, requested);
563
+ const missingKeys = requestedKeys.filter((key) => duplicated.has(key) || !firstSeen.has(key));
564
+ for (const key of duplicated) {
565
+ firstSeen.delete(key);
468
566
  }
469
- return values;
567
+ return { accepted: firstSeen, missingKeys };
470
568
  }
471
569
  function reconcileResult(raw, requestedKeys) {
472
570
  const parsed = translationsResultSchema.safeParse(raw);
@@ -478,17 +576,54 @@ function reconcileResult(raw, requestedKeys) {
478
576
  }
479
577
  return reconcile(parsed.data.translations, requestedKeys);
480
578
  }
579
+ var MAX_REPAIR_ROUNDS = 1;
481
580
  async function runLlmTranslation(request, mechanism) {
482
581
  const data = validateRequest(request);
483
- const payloadJson = JSON.stringify(buildDataPayload(data));
484
- const requestedKeys = data.entries.map((entry) => entry.key);
485
- const completion = await mechanism.translate({ payloadJson, requestedKeys });
486
- const values = reconcileResult(completion.raw, requestedKeys);
582
+ const signal = request.signal;
583
+ const first = await requestTranslations(mechanism, data, signal);
584
+ const values = first.outcome.accepted;
585
+ let usage = first.completion.usage;
586
+ let toRepair = entriesFor(data.entries, first.outcome.missingKeys);
587
+ for (let round = 0; round < MAX_REPAIR_ROUNDS && toRepair.length > 0; round += 1) {
588
+ const repair = await requestTranslations(mechanism, { ...data, entries: toRepair }, signal);
589
+ for (const [key, value] of repair.outcome.accepted) {
590
+ values.set(key, value);
591
+ }
592
+ usage = mergeUsage(usage, repair.completion.usage);
593
+ toRepair = entriesFor(data.entries, repair.outcome.missingKeys);
594
+ }
487
595
  const integrity = checkBatchIntegrity(
488
596
  toIntegrityInputs(data.entries, values),
489
- request.extractPlaceholders
597
+ request.extractPlaceholders,
598
+ request.comparePlaceholders
490
599
  );
491
- return completion.usage === void 0 ? { values, integrity } : { values, integrity, usage: completion.usage };
600
+ return usage === void 0 ? { values, integrity, notices: [] } : { values, integrity, usage, notices: [] };
601
+ }
602
+ async function requestTranslations(mechanism, data, signal) {
603
+ const payloadJson = JSON.stringify(buildDataPayload(data));
604
+ const requestedKeys = data.entries.map((entry) => entry.key);
605
+ const completion = await mechanism.translate({
606
+ payloadJson,
607
+ requestedKeys,
608
+ ...signal !== void 0 ? { signal } : {}
609
+ });
610
+ return { completion, outcome: reconcileResult(completion.raw, requestedKeys) };
611
+ }
612
+ function entriesFor(entries, keys) {
613
+ const wanted = new Set(keys);
614
+ return entries.filter((entry) => wanted.has(entry.key));
615
+ }
616
+ function mergeUsage(first, second) {
617
+ if (first === void 0) {
618
+ return second;
619
+ }
620
+ if (second === void 0) {
621
+ return first;
622
+ }
623
+ return {
624
+ inputTokens: first.inputTokens + second.inputTokens,
625
+ outputTokens: first.outputTokens + second.outputTokens
626
+ };
492
627
  }
493
628
  var OUTPUT_TRUNCATED_MESSAGE = "The provider stopped because the output-token limit was reached. Reduce the batch size or raise the configured max output tokens.";
494
629
  function assertNotTruncated(truncated) {
@@ -521,12 +656,29 @@ function requireGeminiKey() {
521
656
  function requireDeepLKey() {
522
657
  return readRequiredEnv(PROVIDER_ENV.deepl);
523
658
  }
659
+ var OPENAI_COMPATIBLE_ENV_VAR = "OPENAI_COMPATIBLE_API_KEY";
660
+ var OPENAI_COMPATIBLE_KEY_PLACEHOLDER = "local";
661
+ function resolveOpenAiCompatibleKey(customEnvVar) {
662
+ const varName = customEnvVar ?? OPENAI_COMPATIBLE_ENV_VAR;
663
+ const value = process.env[varName];
664
+ if (value !== void 0 && value.length > 0) {
665
+ return value;
666
+ }
667
+ if (customEnvVar !== void 0) {
668
+ throw new ProviderError(
669
+ "MISSING_API_KEY",
670
+ `The ${customEnvVar} environment variable is not set.`
671
+ );
672
+ }
673
+ return OPENAI_COMPATIBLE_KEY_PLACEHOLDER;
674
+ }
524
675
  function createDefaultClient() {
525
676
  const sdk = new Anthropic__default.default({ apiKey: requireAnthropicKey(), logLevel: "off" });
526
677
  return {
527
678
  messages: {
528
- create: async (body) => await sdk.messages.create(
529
- body
679
+ create: async (body, options) => await sdk.messages.create(
680
+ body,
681
+ options
530
682
  )
531
683
  }
532
684
  };
@@ -594,9 +746,9 @@ function createAnthropicProvider(config, deps = {}) {
594
746
  }
595
747
  function createMechanism(client, config) {
596
748
  return {
597
- translate: async ({ payloadJson }) => {
749
+ translate: async ({ payloadJson, signal }) => {
598
750
  const body = buildRequest(config, payloadJson);
599
- const message = await callClient(client, body);
751
+ const message = await callClient(client, body, signal);
600
752
  assertNotTruncated(message.stop_reason === "max_tokens");
601
753
  const raw = requireToolInput(message.content);
602
754
  const usage = toUsage(message.usage);
@@ -604,8 +756,11 @@ function createMechanism(client, config) {
604
756
  }
605
757
  };
606
758
  }
607
- function callClient(client, body) {
608
- return guardProviderCall(() => client.messages.create(body));
759
+ function callClient(client, body, signal) {
760
+ return guardProviderCall(
761
+ () => client.messages.create(body, signal !== void 0 ? { signal } : void 0),
762
+ signal
763
+ );
609
764
  }
610
765
  function toUsage(usage) {
611
766
  if (usage === void 0) {
@@ -700,7 +855,7 @@ function zipResults(entries, results) {
700
855
  values.set(entry.key, translatedValue);
701
856
  integrityInputs.push({
702
857
  key: entry.key,
703
- sourcePlaceholders: entry.placeholders,
858
+ sourceValue: entry.value,
704
859
  translatedValue
705
860
  });
706
861
  }
@@ -716,7 +871,10 @@ function createDeepLProvider(config, deps = {}) {
716
871
  return {
717
872
  id: PROVIDER_ID2,
718
873
  kind: "machine-translation",
719
- supportsGlossary: true,
874
+ // Honest capability signal: DeepL only ever applies a pre-created native glossary id, never a
875
+ // generic term map (see buildTranslateOptions). Without one configured, a supplied term map is
876
+ // silently ignored and surfaces only as a GLOSSARY_IGNORED notice.
877
+ supportsGlossary: validConfig.glossaryId !== void 0,
720
878
  translateBatch: (request) => translate(bundle, validConfig, request)
721
879
  };
722
880
  }
@@ -741,36 +899,97 @@ async function translate(bundle, config, request) {
741
899
  data,
742
900
  protectable,
743
901
  options,
744
- request.extractPlaceholders
902
+ request.extractPlaceholders,
903
+ request.comparePlaceholders,
904
+ request.signal
745
905
  );
746
906
  if (unprotectable.length > 0) {
747
907
  notices.push({ code: "PLACEHOLDER_UNSUPPORTED", message: PLACEHOLDER_UNSUPPORTED_MESSAGE });
748
908
  }
749
909
  return { values, integrity, notices };
750
910
  }
751
- async function translateProtectable(client, data, protectable, options, extract) {
911
+ async function translateProtectable(client, data, protectable, options, extract, compare, signal) {
752
912
  if (protectable.length === 0) {
753
913
  return { values: /* @__PURE__ */ new Map(), integrity: /* @__PURE__ */ new Map() };
754
914
  }
755
915
  const texts = protectable.map((entry) => entry.value);
756
- const results = await callClient2(client, texts, data.sourceLocale, data.targetLocale, options);
916
+ const results = await callClient2(
917
+ client,
918
+ texts,
919
+ data.sourceLocale,
920
+ data.targetLocale,
921
+ options,
922
+ signal
923
+ );
757
924
  const { values, integrityInputs } = zipResults(protectable, results);
758
- const integrity = checkBatchIntegrity(integrityInputs, extract);
925
+ const integrity = checkBatchIntegrity(integrityInputs, extract, compare);
759
926
  return { values, integrity };
760
927
  }
761
- function callClient2(client, texts, sourceLang, targetLang, options) {
762
- return guardProviderCall(() => client.translateText(texts, sourceLang, targetLang, options));
928
+ function callClient2(client, texts, sourceLang, targetLang, options, signal) {
929
+ return guardProviderCall(
930
+ () => client.translateText(texts, sourceLang, targetLang, options),
931
+ signal
932
+ );
763
933
  }
764
934
  var geminiConfigSchema = zod.z.object({
765
935
  model: zod.z.string().min(1),
766
936
  maxOutputTokens: zod.z.number().int().positive()
767
937
  });
938
+ var DEFAULT_GEMINI_RETRY = { attempts: 3, baseDelayMs: 250 };
939
+ function isRetryableStatus(error) {
940
+ const status = getErrorStatus(error);
941
+ return status === 429 || status !== void 0 && status >= 500 && status < 600;
942
+ }
943
+ function isAborted(signal) {
944
+ return signal?.aborted === true;
945
+ }
946
+ function throwAbort(signal) {
947
+ signal?.throwIfAborted();
948
+ throw new DOMException("This operation was aborted.", "AbortError");
949
+ }
950
+ function delay(ms, signal) {
951
+ return new Promise((resolve7) => {
952
+ const onAbort = () => {
953
+ clearTimeout(timer);
954
+ resolve7();
955
+ };
956
+ const timer = setTimeout(() => {
957
+ signal?.removeEventListener("abort", onAbort);
958
+ resolve7();
959
+ }, ms);
960
+ signal?.addEventListener("abort", onAbort, { once: true });
961
+ });
962
+ }
963
+ async function withGeminiRetry(call, signal, config = DEFAULT_GEMINI_RETRY) {
964
+ let attempt = 1;
965
+ while (true) {
966
+ try {
967
+ return await call();
968
+ } catch (error) {
969
+ if (isAborted(signal)) {
970
+ throwAbort(signal);
971
+ }
972
+ const exhausted = attempt >= config.attempts;
973
+ if (exhausted || !isRetryableStatus(error)) {
974
+ throw error;
975
+ }
976
+ await delay(config.baseDelayMs * 2 ** (attempt - 1), signal);
977
+ if (isAborted(signal)) {
978
+ throwAbort(signal);
979
+ }
980
+ attempt += 1;
981
+ }
982
+ }
983
+ }
768
984
  function createDefaultClient3() {
769
985
  const ai = new genai.GoogleGenAI({ apiKey: requireGeminiKey() });
770
986
  return {
771
987
  models: {
772
- generateContent: async (request) => await ai.models.generateContent(
773
- request
988
+ generateContent: (request) => withGeminiRetry(
989
+ async () => await ai.models.generateContent(
990
+ request
991
+ ),
992
+ request.config.abortSignal
774
993
  )
775
994
  }
776
995
  };
@@ -834,7 +1053,7 @@ var GEMINI_SYSTEM_RULES = [
834
1053
  "When a tone is provided, honor it.",
835
1054
  "Respond only with the structured object: exactly one entry per requested key, no commentary, no extra keys, and no key that was not requested."
836
1055
  ].join("\n");
837
- function buildGeminiRequest(config, payloadJson) {
1056
+ function buildGeminiRequest(config, payloadJson, signal) {
838
1057
  return {
839
1058
  model: config.model,
840
1059
  contents: [{ role: "user", parts: [{ text: payloadJson }] }],
@@ -842,7 +1061,8 @@ function buildGeminiRequest(config, payloadJson) {
842
1061
  systemInstruction: GEMINI_SYSTEM_RULES,
843
1062
  responseMimeType: "application/json",
844
1063
  responseSchema: toGeminiSchema(deriveJsonSchema(translationsResultSchema)),
845
- maxOutputTokens: config.maxOutputTokens
1064
+ maxOutputTokens: config.maxOutputTokens,
1065
+ ...signal !== void 0 ? { abortSignal: signal } : {}
846
1066
  }
847
1067
  };
848
1068
  }
@@ -906,15 +1126,15 @@ function createGeminiProvider(config, deps = {}) {
906
1126
  }
907
1127
  function createMechanism2(client, config) {
908
1128
  return {
909
- translate: async ({ payloadJson }) => {
910
- const request = buildGeminiRequest(config, payloadJson);
911
- const response = await callClient3(client, request);
1129
+ translate: async ({ payloadJson, signal }) => {
1130
+ const request = buildGeminiRequest(config, payloadJson, signal);
1131
+ const response = await callClient3(client, request, signal);
912
1132
  return extractGeminiResult(response);
913
1133
  }
914
1134
  };
915
1135
  }
916
- function callClient3(client, request) {
917
- return guardProviderCall(() => client.models.generateContent(request));
1136
+ function callClient3(client, request, signal) {
1137
+ return guardProviderCall(() => client.models.generateContent(request), signal);
918
1138
  }
919
1139
  var openAiConfigSchema = zod.z.object({
920
1140
  model: zod.z.string().min(1),
@@ -925,8 +1145,9 @@ function createDefaultClient4() {
925
1145
  return {
926
1146
  chat: {
927
1147
  completions: {
928
- create: async (body) => await sdk.chat.completions.create(
929
- body
1148
+ create: async (body, options) => await sdk.chat.completions.create(
1149
+ body,
1150
+ options
930
1151
  )
931
1152
  }
932
1153
  }
@@ -944,27 +1165,90 @@ var OPENAI_SYSTEM_RULES = [
944
1165
  "When a tone is provided, honor it.",
945
1166
  "Respond only with the structured object: exactly one entry per requested key, no commentary, no extra keys, and no key that was not requested."
946
1167
  ].join("\n");
947
- function buildOpenAiRequest(config, payloadJson) {
1168
+ function buildResponseFormat(mode) {
1169
+ if (mode === "json-object") {
1170
+ return { type: "json_object" };
1171
+ }
948
1172
  return {
949
- model: config.model,
950
- max_completion_tokens: config.maxOutputTokens,
1173
+ type: "json_schema",
1174
+ json_schema: {
1175
+ name: RESULT_SCHEMA_NAME,
1176
+ strict: true,
1177
+ schema: deriveJsonSchema(translationsResultSchema)
1178
+ }
1179
+ };
1180
+ }
1181
+ function buildOpenAiRequest(config, payloadJson, mode = "strict-schema", tokenLimitField = "max_completion_tokens") {
1182
+ const base = {
951
1183
  messages: [
952
1184
  { role: "system", content: OPENAI_SYSTEM_RULES },
953
1185
  { role: "user", content: payloadJson }
954
1186
  ],
955
- response_format: {
956
- type: "json_schema",
957
- json_schema: {
958
- name: RESULT_SCHEMA_NAME,
959
- strict: true,
960
- schema: deriveJsonSchema(translationsResultSchema)
1187
+ model: config.model,
1188
+ response_format: buildResponseFormat(mode)
1189
+ };
1190
+ if (tokenLimitField === "max_tokens") {
1191
+ return { ...base, max_tokens: config.maxOutputTokens };
1192
+ }
1193
+ return { ...base, max_completion_tokens: config.maxOutputTokens };
1194
+ }
1195
+ function advanceStringScan(char, escaped) {
1196
+ if (escaped) {
1197
+ return { inString: true, escaped: false };
1198
+ }
1199
+ if (char === "\\") {
1200
+ return { inString: true, escaped: true };
1201
+ }
1202
+ return { inString: char !== '"', escaped: false };
1203
+ }
1204
+ function scanBalancedObjectEnd(content, start) {
1205
+ let depth = 0;
1206
+ let inString = false;
1207
+ let escaped = false;
1208
+ for (let i = start; i < content.length; i += 1) {
1209
+ const char = content[i];
1210
+ if (inString) {
1211
+ ({ inString, escaped } = advanceStringScan(char, escaped));
1212
+ continue;
1213
+ }
1214
+ if (char === '"') {
1215
+ inString = true;
1216
+ } else if (char === "{") {
1217
+ depth += 1;
1218
+ } else if (char === "}") {
1219
+ depth -= 1;
1220
+ if (depth === 0) {
1221
+ return i;
961
1222
  }
962
1223
  }
963
- };
1224
+ }
1225
+ return null;
964
1226
  }
965
- function parseContent2(content) {
1227
+ function extractBalancedJson(content) {
1228
+ let searchFrom = 0;
1229
+ while (searchFrom < content.length) {
1230
+ const start = content.indexOf("{", searchFrom);
1231
+ if (start === -1) {
1232
+ return null;
1233
+ }
1234
+ const end = scanBalancedObjectEnd(content, start);
1235
+ if (end === null) {
1236
+ return null;
1237
+ }
1238
+ const candidate = content.slice(start, end + 1);
1239
+ try {
1240
+ JSON.parse(candidate);
1241
+ return candidate;
1242
+ } catch {
1243
+ searchFrom = end + 1;
1244
+ }
1245
+ }
1246
+ return null;
1247
+ }
1248
+ function parseContent2(content, tolerant) {
1249
+ const candidate = tolerant ? extractBalancedJson(content) ?? content : content;
966
1250
  try {
967
- return JSON.parse(content);
1251
+ return JSON.parse(candidate);
968
1252
  } catch {
969
1253
  throw new ProviderError("INVALID_RESPONSE", "The provider returned unparseable content.");
970
1254
  }
@@ -979,7 +1263,7 @@ function toUsage3(usage) {
979
1263
  }
980
1264
  return { inputTokens: prompt_tokens, outputTokens: completion_tokens };
981
1265
  }
982
- function extractOpenAiResult(completion) {
1266
+ function extractOpenAiResult(completion, tolerant = false) {
983
1267
  const choice = completion.choices[0];
984
1268
  if (choice === void 0) {
985
1269
  throw new ProviderError("INVALID_RESPONSE", "The provider returned no message.");
@@ -992,7 +1276,7 @@ function extractOpenAiResult(completion) {
992
1276
  if (message.content === void 0 || message.content === null) {
993
1277
  throw new ProviderError("INVALID_RESPONSE", "The provider returned no translation content.");
994
1278
  }
995
- const raw = parseContent2(message.content);
1279
+ const raw = parseContent2(message.content, tolerant);
996
1280
  const usage = toUsage3(completion.usage);
997
1281
  return usage === void 0 ? { raw } : { raw, usage };
998
1282
  }
@@ -1010,15 +1294,84 @@ function createOpenAiProvider(config, deps = {}) {
1010
1294
  }
1011
1295
  function createMechanism3(client, config) {
1012
1296
  return {
1013
- translate: async ({ payloadJson }) => {
1297
+ translate: async ({ payloadJson, signal }) => {
1014
1298
  const body = buildOpenAiRequest(config, payloadJson);
1015
- const completion = await callClient4(client, body);
1299
+ const completion = await callClient4(client, body, signal);
1016
1300
  return extractOpenAiResult(completion);
1017
1301
  }
1018
1302
  };
1019
1303
  }
1020
- function callClient4(client, body) {
1021
- return guardProviderCall(() => client.chat.completions.create(body));
1304
+ function callClient4(client, body, signal) {
1305
+ return guardProviderCall(
1306
+ () => client.chat.completions.create(body, signal !== void 0 ? { signal } : void 0),
1307
+ signal
1308
+ );
1309
+ }
1310
+ var HOSTED_PROVIDER_ENV_VARS = new Set(
1311
+ Object.values(PROVIDER_ENV).map((name) => name.toUpperCase())
1312
+ );
1313
+ function isNotHostedProviderEnvVar(value) {
1314
+ return !HOSTED_PROVIDER_ENV_VARS.has(value.toUpperCase());
1315
+ }
1316
+ function isHttpOrHttpsUrl(value) {
1317
+ try {
1318
+ const { protocol } = new URL(value);
1319
+ return protocol === "http:" || protocol === "https:";
1320
+ } catch {
1321
+ return false;
1322
+ }
1323
+ }
1324
+ var openAiCompatibleConfigSchema = zod.z.object({
1325
+ baseUrl: zod.z.url({ message: "baseUrl must be a valid absolute URL." }).refine(isHttpOrHttpsUrl, { message: "baseUrl must use the http or https scheme." }),
1326
+ model: zod.z.string().min(1),
1327
+ maxOutputTokens: zod.z.number().int().positive(),
1328
+ apiKeyEnvVar: zod.z.string().min(1).refine(isNotHostedProviderEnvVar, {
1329
+ message: "apiKeyEnvVar must not name a hosted provider's environment variable."
1330
+ }).optional()
1331
+ });
1332
+ function createDefaultClient5(config) {
1333
+ const sdk = new OpenAI__default.default({
1334
+ apiKey: resolveOpenAiCompatibleKey(config.apiKeyEnvVar),
1335
+ baseURL: config.baseUrl,
1336
+ logLevel: "off"
1337
+ });
1338
+ return {
1339
+ chat: {
1340
+ completions: {
1341
+ create: async (body, options) => await sdk.chat.completions.create(
1342
+ body,
1343
+ options
1344
+ )
1345
+ }
1346
+ }
1347
+ };
1348
+ }
1349
+ var PROVIDER_ID5 = "openai-compatible";
1350
+ function createOpenAiCompatibleProvider(config, deps = {}) {
1351
+ const validConfig = openAiCompatibleConfigSchema.parse(config);
1352
+ const client = deps.client ?? createDefaultClient5(validConfig);
1353
+ const mechanism = createMechanism4(client, validConfig);
1354
+ return {
1355
+ id: PROVIDER_ID5,
1356
+ kind: "llm",
1357
+ supportsGlossary: true,
1358
+ translateBatch: (request) => runLlmTranslation(request, mechanism)
1359
+ };
1360
+ }
1361
+ function createMechanism4(client, config) {
1362
+ return {
1363
+ translate: async ({ payloadJson, signal }) => {
1364
+ const body = buildOpenAiRequest(config, payloadJson, "strict-schema", "max_tokens");
1365
+ const completion = await callClient5(client, body, signal);
1366
+ return extractOpenAiResult(completion, true);
1367
+ }
1368
+ };
1369
+ }
1370
+ function callClient5(client, body, signal) {
1371
+ return guardProviderCall(
1372
+ () => client.chat.completions.create(body, signal !== void 0 ? { signal } : void 0),
1373
+ signal
1374
+ );
1022
1375
  }
1023
1376
  var SCAFFOLD_MODELS = {
1024
1377
  anthropic: "claude-sonnet-4-6",
@@ -1029,13 +1382,18 @@ var providerConfigSchema = zod.z.discriminatedUnion("id", [
1029
1382
  zod.z.object({ id: zod.z.literal("anthropic"), options: anthropicConfigSchema.strict() }),
1030
1383
  zod.z.object({ id: zod.z.literal("openai"), options: openAiConfigSchema.strict() }),
1031
1384
  zod.z.object({ id: zod.z.literal("gemini"), options: geminiConfigSchema.strict() }),
1032
- zod.z.object({ id: zod.z.literal("deepl"), options: deepLConfigSchema.strict() })
1385
+ zod.z.object({ id: zod.z.literal("deepl"), options: deepLConfigSchema.strict() }),
1386
+ zod.z.object({
1387
+ id: zod.z.literal("openai-compatible"),
1388
+ options: openAiCompatibleConfigSchema.strict()
1389
+ })
1033
1390
  ]);
1034
1391
  var providerFactories = {
1035
1392
  anthropic: (options) => createAnthropicProvider(options),
1036
1393
  openai: (options) => createOpenAiProvider(options),
1037
1394
  gemini: (options) => createGeminiProvider(options),
1038
- deepl: (options) => createDeepLProvider(options)
1395
+ deepl: (options) => createDeepLProvider(options),
1396
+ "openai-compatible": (options) => createOpenAiCompatibleProvider(options)
1039
1397
  };
1040
1398
  function buildProvider(config) {
1041
1399
  const create = providerFactories[config.id];
@@ -1214,6 +1572,12 @@ async function readLockFile(path, fs) {
1214
1572
  if (!result.success) {
1215
1573
  throw new SdkError("LOCK_FILE_INVALID", `The lock-file at ${path} has an unexpected shape.`);
1216
1574
  }
1575
+ if (result.data.version !== CURRENT_VERSION) {
1576
+ throw new SdkError(
1577
+ "LOCK_FILE_INVALID",
1578
+ `The lock-file at ${path} has version ${result.data.version}, but this version of verbatra supports version ${CURRENT_VERSION}.`
1579
+ );
1580
+ }
1217
1581
  return result.data;
1218
1582
  }
1219
1583
  function baselineFor(lock, locale) {
@@ -1346,6 +1710,150 @@ function icuInvalidKeys(entries) {
1346
1710
  }
1347
1711
  return invalid;
1348
1712
  }
1713
+ function tokenOf2(element) {
1714
+ switch (element.type) {
1715
+ case icuMessageformatParser.TYPE.argument:
1716
+ case icuMessageformatParser.TYPE.number:
1717
+ case icuMessageformatParser.TYPE.date:
1718
+ case icuMessageformatParser.TYPE.time:
1719
+ case icuMessageformatParser.TYPE.select:
1720
+ case icuMessageformatParser.TYPE.plural:
1721
+ return `{${element.value}}`;
1722
+ case icuMessageformatParser.TYPE.tag:
1723
+ return `<${element.value}>`;
1724
+ default:
1725
+ return void 0;
1726
+ }
1727
+ }
1728
+ function isBranching(element) {
1729
+ return element.type === icuMessageformatParser.TYPE.plural || element.type === icuMessageformatParser.TYPE.select;
1730
+ }
1731
+ function isTag(element) {
1732
+ return element.type === icuMessageformatParser.TYPE.tag;
1733
+ }
1734
+ function layerTokens(elements) {
1735
+ const tokens = [];
1736
+ for (const element of elements) {
1737
+ const token = tokenOf2(element);
1738
+ if (token !== void 0) {
1739
+ tokens.push(token);
1740
+ }
1741
+ }
1742
+ return tokens;
1743
+ }
1744
+ function deepTokens(elements) {
1745
+ const tokens = [];
1746
+ for (const element of elements) {
1747
+ const token = tokenOf2(element);
1748
+ if (token !== void 0) {
1749
+ tokens.push(token);
1750
+ }
1751
+ if (isTag(element)) {
1752
+ tokens.push(...deepTokens(element.children));
1753
+ } else if (isBranching(element)) {
1754
+ for (const option of Object.values(element.options)) {
1755
+ tokens.push(...deepTokens(option.value));
1756
+ }
1757
+ }
1758
+ }
1759
+ return tokens;
1760
+ }
1761
+ function branchesByCategory(element) {
1762
+ return new Map(
1763
+ Object.entries(element.options).map(([category, option]) => [category, option.value])
1764
+ );
1765
+ }
1766
+ function findUnconsumed(target, consumed, isMatch) {
1767
+ for (let index = 0; index < target.length; index += 1) {
1768
+ if (consumed.has(index)) {
1769
+ continue;
1770
+ }
1771
+ const candidate = target[index];
1772
+ if (candidate !== void 0 && isMatch(candidate)) {
1773
+ return { element: candidate, index };
1774
+ }
1775
+ }
1776
+ return void 0;
1777
+ }
1778
+ function findMatchingBranching(source, target, consumed) {
1779
+ return findUnconsumed(
1780
+ target,
1781
+ consumed,
1782
+ (candidate) => isBranching(candidate) && candidate.value === source.value
1783
+ );
1784
+ }
1785
+ function findMatchingTag(source, target, consumed) {
1786
+ return findUnconsumed(
1787
+ target,
1788
+ consumed,
1789
+ (candidate) => isTag(candidate) && candidate.value === source.value
1790
+ );
1791
+ }
1792
+ function combineResults(results) {
1793
+ const missing = results.flatMap((result) => result.missing).sort();
1794
+ const extra = results.flatMap((result) => result.extra).sort();
1795
+ return { matches: missing.length === 0 && extra.length === 0, missing, extra, reordered: false };
1796
+ }
1797
+ function compareAgainstSourceUnion(sourceBranches, targetBranch) {
1798
+ const union = /* @__PURE__ */ new Set();
1799
+ for (const branch of sourceBranches.values()) {
1800
+ for (const token of deepTokens(branch)) {
1801
+ union.add(token);
1802
+ }
1803
+ }
1804
+ const extra = deepTokens(targetBranch).filter((token) => !union.has(token)).sort();
1805
+ return { matches: extra.length === 0, missing: [], extra, reordered: false };
1806
+ }
1807
+ function compareBranching(source, target) {
1808
+ const sourceBranches = branchesByCategory(source);
1809
+ const targetBranches = branchesByCategory(target);
1810
+ const categories = /* @__PURE__ */ new Set([...sourceBranches.keys(), ...targetBranches.keys()]);
1811
+ const results = [];
1812
+ for (const category of categories) {
1813
+ const sourceBranch = sourceBranches.get(category);
1814
+ const targetBranch = targetBranches.get(category);
1815
+ if (sourceBranch !== void 0 && targetBranch !== void 0) {
1816
+ results.push(compareElements(sourceBranch, targetBranch));
1817
+ } else if (targetBranch !== void 0) {
1818
+ results.push(compareAgainstSourceUnion(sourceBranches, targetBranch));
1819
+ }
1820
+ }
1821
+ return combineResults(results);
1822
+ }
1823
+ function nestedResults(source, target) {
1824
+ const results = [];
1825
+ const consumed = /* @__PURE__ */ new Set();
1826
+ for (const element of source) {
1827
+ if (isBranching(element)) {
1828
+ const found = findMatchingBranching(element, target, consumed);
1829
+ if (found !== void 0) {
1830
+ consumed.add(found.index);
1831
+ results.push(compareBranching(element, found.element));
1832
+ }
1833
+ } else if (isTag(element)) {
1834
+ const found = findMatchingTag(element, target, consumed);
1835
+ if (found !== void 0) {
1836
+ consumed.add(found.index);
1837
+ results.push(compareElements(element.children, found.element.children));
1838
+ }
1839
+ }
1840
+ }
1841
+ return results;
1842
+ }
1843
+ function compareElements(source, target) {
1844
+ const layer = checkPlaceholders(layerTokens(source), layerTokens(target));
1845
+ const nested = nestedResults(source, target);
1846
+ return nested.length === 0 ? layer : combineResults([layer, ...nested]);
1847
+ }
1848
+ function compareIcuPlaceholders(sourceValue, targetValue) {
1849
+ try {
1850
+ const sourceAst = icuMessageformatParser.parse(sourceValue);
1851
+ const targetAst = icuMessageformatParser.parse(targetValue);
1852
+ return compareElements(sourceAst, targetAst);
1853
+ } catch {
1854
+ return checkPlaceholders(icuPlaceholders(sourceValue), icuPlaceholders(targetValue));
1855
+ }
1856
+ }
1349
1857
  var AdapterError = class extends Error {
1350
1858
  code;
1351
1859
  constructor(code, message) {
@@ -1454,6 +1962,9 @@ async function atomicWriteFile(path, data, ops = nodeOps) {
1454
1962
  throw error;
1455
1963
  }
1456
1964
  }
1965
+ function stripLeadingBom(content) {
1966
+ return content.charCodeAt(0) === 65279 ? content.slice(1) : content;
1967
+ }
1457
1968
  async function readBoundedUtf82(handle, size) {
1458
1969
  const buffer = Buffer.allocUnsafe(size);
1459
1970
  let offset = 0;
@@ -1464,7 +1975,7 @@ async function readBoundedUtf82(handle, size) {
1464
1975
  }
1465
1976
  offset += bytesRead;
1466
1977
  }
1467
- return buffer.toString("utf8", 0, offset);
1978
+ return stripLeadingBom(buffer.toString("utf8", 0, offset));
1468
1979
  }
1469
1980
  async function readBounded2(filePath) {
1470
1981
  const handle = await promises.open(filePath, "r");
@@ -1647,9 +2158,9 @@ function unflattenEntries(entries) {
1647
2158
  }
1648
2159
  return root;
1649
2160
  }
1650
- function toEntries(content, namespace, parse2, deriveEntry, keyMode, validateTree) {
2161
+ function toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTree) {
1651
2162
  try {
1652
- const tree = parse2(content);
2163
+ const tree = parse3(content);
1653
2164
  validateTree?.(tree);
1654
2165
  return flattenTree(tree, namespace, deriveEntry, keyMode);
1655
2166
  } catch (error) {
@@ -1661,7 +2172,7 @@ function createTreeFileAdapter(options) {
1661
2172
  format,
1662
2173
  extensions,
1663
2174
  sniff,
1664
- parse: parse2,
2175
+ parse: parse3,
1665
2176
  serialize,
1666
2177
  deriveEntry,
1667
2178
  extractPlaceholders,
@@ -1669,6 +2180,7 @@ function createTreeFileAdapter(options) {
1669
2180
  validateMessage,
1670
2181
  validateTree,
1671
2182
  buildWriteTree,
2183
+ comparePlaceholders,
1672
2184
  keyMode = "literal-leaf"
1673
2185
  } = options;
1674
2186
  return {
@@ -1676,10 +2188,11 @@ function createTreeFileAdapter(options) {
1676
2188
  canHandle: buildCanHandle(extensions, sniff),
1677
2189
  extractPlaceholders,
1678
2190
  validateMessage: validateMessage ?? (() => true),
2191
+ ...comparePlaceholders !== void 0 ? { comparePlaceholders } : {},
1679
2192
  async read(filePath, locale) {
1680
2193
  const content = await readFileContent(filePath);
1681
2194
  const namespace = namespaceOf(filePath);
1682
- const entries = toEntries(content, namespace, parse2, deriveEntry, keyMode, validateTree);
2195
+ const entries = toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTree);
1683
2196
  const resource = { locale, namespace, format, entries };
1684
2197
  const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
1685
2198
  return { resource, invalidIcuKeys };
@@ -1775,6 +2288,7 @@ function createArbAdapter() {
1775
2288
  deriveEntry: icuDeriveEntry,
1776
2289
  computeInvalidIcuKeys: icuInvalidKeys,
1777
2290
  validateMessage: icuIsValid,
2291
+ comparePlaceholders: compareIcuPlaceholders,
1778
2292
  buildWriteTree: buildArbWriteTree
1779
2293
  });
1780
2294
  }
@@ -1838,7 +2352,8 @@ function createNextIntlJsonAdapter() {
1838
2352
  extractPlaceholders: icuPlaceholders,
1839
2353
  deriveEntry: icuDeriveEntry,
1840
2354
  computeInvalidIcuKeys: icuInvalidKeys,
1841
- validateMessage: icuIsValid
2355
+ validateMessage: icuIsValid,
2356
+ comparePlaceholders: compareIcuPlaceholders
1842
2357
  });
1843
2358
  }
1844
2359
  function assertNoDottedNestedKey(tree) {
@@ -2040,6 +2555,7 @@ function extractXliffPlaceholders(value) {
2040
2555
  return result;
2041
2556
  }
2042
2557
  var ELEMENT_NODE = 1;
2558
+ var TEXT_NODE = 3;
2043
2559
  function isElement(node) {
2044
2560
  return node.nodeType === ELEMENT_NODE;
2045
2561
  }
@@ -2130,6 +2646,12 @@ function parseXliffEntries(content, namespace) {
2130
2646
  const serializer = new xmldom.XMLSerializer();
2131
2647
  const out = /* @__PURE__ */ new Map();
2132
2648
  for (const unit of walkUnits(root)) {
2649
+ if (out.has(unit.key)) {
2650
+ throw new AdapterError(
2651
+ "INVALID_STRUCTURE",
2652
+ "The XLIFF file has two trans-units with the same id."
2653
+ );
2654
+ }
2133
2655
  const value = unitValue(serializer, unit);
2134
2656
  out.set(unit.key, {
2135
2657
  key: unit.key,
@@ -2156,10 +2678,67 @@ async function readDestination(filePath) {
2156
2678
  }
2157
2679
  return outcome.content;
2158
2680
  }
2681
+ var XLIFF_INLINE_ELEMENTS = /* @__PURE__ */ new Set(["x", "g", "bx", "ex", "ph", "it", "mrk"]);
2682
+ var XLIFF_NAMESPACES = /* @__PURE__ */ new Set([
2683
+ "urn:oasis:names:tc:xliff:document:1.2",
2684
+ "urn:oasis:names:tc:xliff:document:2.0"
2685
+ ]);
2686
+ var INLINE_ELEMENT_ATTRIBUTES = {
2687
+ x: ["id", "ctype"],
2688
+ ph: ["id", "ctype"],
2689
+ g: ["id", "ctype"],
2690
+ bx: ["id", "rid", "ctype"],
2691
+ ex: ["id", "rid"],
2692
+ it: ["id", "pos", "ctype"],
2693
+ mrk: ["id", "mtype"]
2694
+ };
2695
+ function isAllowedInlineElement(element) {
2696
+ const namespace = element.namespaceURI;
2697
+ const hasAllowedNamespace = namespace === null || XLIFF_NAMESPACES.has(namespace);
2698
+ return hasAllowedNamespace && XLIFF_INLINE_ELEMENTS.has(element.localName ?? "");
2699
+ }
2700
+ function isAllowedFragmentNode(node) {
2701
+ if (node.nodeType === TEXT_NODE) {
2702
+ return true;
2703
+ }
2704
+ return isElement(node) && isAllowedInlineElement(node);
2705
+ }
2706
+ function allDescendantNodes(node) {
2707
+ const children = Array.from(node.childNodes);
2708
+ return children.flatMap((child) => [child, ...allDescendantNodes(child)]);
2709
+ }
2710
+ function hasDisallowedNode(root) {
2711
+ return allDescendantNodes(root).some((node) => !isAllowedFragmentNode(node));
2712
+ }
2713
+ function attributeNames(element) {
2714
+ const names = [];
2715
+ for (let i = 0; i < element.attributes.length; i += 1) {
2716
+ const attr = element.attributes.item(i);
2717
+ if (attr !== null) {
2718
+ names.push(attr.name);
2719
+ }
2720
+ }
2721
+ return names;
2722
+ }
2723
+ function sanitizeInlineAttributes(root) {
2724
+ for (const el of collectByTag(root, "*")) {
2725
+ const allowed = INLINE_ELEMENT_ATTRIBUTES[el.localName ?? ""] ?? [];
2726
+ for (const name of attributeNames(el)) {
2727
+ if (!allowed.includes(name)) {
2728
+ el.removeAttribute(name);
2729
+ }
2730
+ }
2731
+ }
2732
+ }
2159
2733
  function fragmentNodes(parser, value) {
2734
+ assertNoDoctype(value);
2160
2735
  try {
2161
2736
  const root = parser.parseFromString(`<wrapper>${value}</wrapper>`, "text/xml").documentElement;
2162
- return root === null ? null : Array.from(root.childNodes);
2737
+ if (root === null || hasDisallowedNode(root)) {
2738
+ return null;
2739
+ }
2740
+ sanitizeInlineAttributes(root);
2741
+ return Array.from(root.childNodes);
2163
2742
  } catch {
2164
2743
  return null;
2165
2744
  }
@@ -2439,15 +3018,8 @@ function subBatchFailedNotice(count, error) {
2439
3018
  }
2440
3019
 
2441
3020
  // src/flow/notices.ts
2442
- function isNotice(value) {
2443
- return typeof value === "object" && value !== null && typeof value.code === "string" && typeof value.message === "string";
2444
- }
2445
3021
  function readNotices(result) {
2446
- const candidate = result.notices;
2447
- if (!Array.isArray(candidate)) {
2448
- return [];
2449
- }
2450
- return candidate.filter(isNotice);
3022
+ return result.notices ?? [];
2451
3023
  }
2452
3024
 
2453
3025
  // src/flow/plural-categories.ts
@@ -2613,7 +3185,8 @@ function buildRequest2(context, entries) {
2613
3185
  entries,
2614
3186
  extractPlaceholders: context.adapter.extractPlaceholders,
2615
3187
  ...context.glossary !== void 0 ? { glossary: context.glossary } : {},
2616
- ...context.tone !== void 0 ? { tone: context.tone } : {}
3188
+ ...context.tone !== void 0 ? { tone: context.tone } : {},
3189
+ ...context.adapter.comparePlaceholders !== void 0 ? { comparePlaceholders: context.adapter.comparePlaceholders } : {}
2617
3190
  };
2618
3191
  }
2619
3192
  async function generatePluralForms(context) {
@@ -2650,20 +3223,26 @@ async function runGenerationSubBatch(context, batch, accepted, withheld, provide
2650
3223
  return [subBatchFailedNotice(batch.length, error)];
2651
3224
  }
2652
3225
  for (const item of batch) {
2653
- const value = result.values.get(item.targetKey);
2654
- const integrity = result.integrity.get(item.targetKey);
2655
- if (value !== void 0 && integrity?.matches === true) {
2656
- accepted.push({
2657
- targetKey: item.targetKey,
2658
- entry: { ...syntheticEntry(item), value },
2659
- lockHash: generatedLockHash(item.governingEntries, item.category)
2660
- });
2661
- } else {
2662
- withheld.push(item.targetKey);
2663
- }
3226
+ foldGenerationItem(item, result, accepted, withheld, providerFailures);
2664
3227
  }
2665
3228
  return readNotices(result);
2666
3229
  }
3230
+ function foldGenerationItem(item, result, accepted, withheld, providerFailures) {
3231
+ const value = result.values.get(item.targetKey);
3232
+ if (value === void 0) {
3233
+ providerFailures.push(item.targetKey);
3234
+ return;
3235
+ }
3236
+ if (result.integrity.get(item.targetKey)?.matches === true) {
3237
+ accepted.push({
3238
+ targetKey: item.targetKey,
3239
+ entry: { ...syntheticEntry(item), value },
3240
+ lockHash: generatedLockHash(item.governingEntries, item.category)
3241
+ });
3242
+ } else {
3243
+ withheld.push(item.targetKey);
3244
+ }
3245
+ }
2667
3246
 
2668
3247
  // src/flow/locale-run.ts
2669
3248
  function emptyResource(locale, format) {
@@ -2683,7 +3262,8 @@ function buildRequest3(params, entries) {
2683
3262
  entries,
2684
3263
  extractPlaceholders: params.adapter.extractPlaceholders,
2685
3264
  ...params.glossary !== void 0 ? { glossary: params.glossary } : {},
2686
- ...params.tone !== void 0 ? { tone: params.tone } : {}
3265
+ ...params.tone !== void 0 ? { tone: params.tone } : {},
3266
+ ...params.adapter.comparePlaceholders !== void 0 ? { comparePlaceholders: params.adapter.comparePlaceholders } : {}
2687
3267
  };
2688
3268
  }
2689
3269
  async function runLocale(params) {
@@ -2850,16 +3430,22 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
2850
3430
  return [subBatchFailedNotice(batch.length, error)];
2851
3431
  }
2852
3432
  for (const entry of batch) {
2853
- const value = result.values.get(entry.key);
2854
- const integrity = result.integrity.get(entry.key);
2855
- if (value !== void 0 && integrity?.matches === true) {
2856
- accepted.set(entry.key, { value, source: entry });
2857
- } else {
2858
- integrityMismatches.push(entry.key);
2859
- }
3433
+ foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures);
2860
3434
  }
2861
3435
  return readNotices(result);
2862
3436
  }
3437
+ function foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures) {
3438
+ const value = result.values.get(entry.key);
3439
+ if (value === void 0) {
3440
+ providerFailures.push(entry.key);
3441
+ return;
3442
+ }
3443
+ if (result.integrity.get(entry.key)?.matches === true) {
3444
+ accepted.set(entry.key, { value, source: entry });
3445
+ } else {
3446
+ integrityMismatches.push(entry.key);
3447
+ }
3448
+ }
2863
3449
  function computeLockEntries(params, merged, withheld, generated) {
2864
3450
  const lockEntries = {};
2865
3451
  const sourceBaseKeys = sourcePluralBaseKeys(params.source);
@@ -3146,7 +3732,7 @@ async function streamEntryBounded(file, remaining) {
3146
3732
  "The workbook decompresses to more than the permitted maximum bytes."
3147
3733
  );
3148
3734
  }
3149
- return Buffer.concat(chunks).toString("utf8");
3735
+ return { raw, content: Buffer.concat(chunks).toString("utf8") };
3150
3736
  }
3151
3737
  async function guardWorkbookBytes(bytes, limits) {
3152
3738
  let zip;
@@ -3178,14 +3764,8 @@ async function guardWorkbookBytes(bytes, limits) {
3178
3764
  let actualTotal = 0;
3179
3765
  for (const file of files) {
3180
3766
  const remaining = limits.maxDecompressedBytes - actualTotal;
3181
- const content = await streamEntryBounded(file, remaining);
3182
- actualTotal += Buffer.byteLength(content, "utf8");
3183
- if (actualTotal > limits.maxDecompressedBytes) {
3184
- throw new ExchangeError(
3185
- "WORKBOOK_INVALID",
3186
- `The workbook decompresses to more than the maximum of ${limits.maxDecompressedBytes} bytes.`
3187
- );
3188
- }
3767
+ const { raw, content } = await streamEntryBounded(file, remaining);
3768
+ actualTotal += raw;
3189
3769
  assertNoDoctype2(file.name, content);
3190
3770
  }
3191
3771
  }
@@ -3374,10 +3954,7 @@ function judge(row, sourceEntry, adapter) {
3374
3954
  if (contentHash(sourceEntry) !== row.sourceHash) {
3375
3955
  return "drift";
3376
3956
  }
3377
- const integrity = checkPlaceholders(
3378
- sourceEntry.placeholders,
3379
- adapter.extractPlaceholders(row.translation)
3380
- );
3957
+ const integrity = adapter.comparePlaceholders !== void 0 ? adapter.comparePlaceholders(sourceEntry.value, row.translation) : checkPlaceholders(sourceEntry.placeholders, adapter.extractPlaceholders(row.translation));
3381
3958
  if (!integrity.matches) {
3382
3959
  return "placeholder";
3383
3960
  }