@verbatra/sdk 0.5.0-next.1 → 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/README.md CHANGED
@@ -71,7 +71,7 @@ export default defineConfig({
71
71
  });
72
72
  ```
73
73
 
74
- `files.pattern` must contain the `{locale}` token, and `targetLocales` must not include `sourceLocale`; both are enforced when the config is validated. The supported `format` values are `i18next-json`, `vue-i18n-json`, `next-intl-json`, `ngx-translate-json`, `xliff`, `yaml`, and `arb`. The optional `glossary` (a term map) and `tone` (`"formal"`, `"informal"`, or `"neutral"`) refine the output. The optional `prune` boolean (off by default) opts in to removing orphaned keys (present in a target file but absent from the source) from the written target files and the lock; the `translate --prune` flag overrides it per run. The optional `generatePlurals` boolean (off by default) opts in to synthesizing the CLDR plural forms a richer target language requires but the source lacks (i18next-JSON projects translated by an LLM provider only; DeepL, non-i18next formats, and unknown languages fall back to the per-locale plural warning and never fail); a per-run `generatePlurals` override on `translate` takes precedence, and generated keys are reported separately from translated keys on the summary. OpenAI and Gemini take `{ model, maxOutputTokens }`; DeepL takes `{}` (with an optional `glossaryId`). API keys are never part of the config. Each provider reads its own environment variable (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPL_API_KEY`).
74
+ `files.pattern` must contain the `{locale}` token, and `targetLocales` must not include `sourceLocale`; both are enforced when the config is validated. The supported `format` values are `i18next-json`, `vue-i18n-json`, `next-intl-json`, `ngx-translate-json`, `xliff`, `yaml`, and `arb`. The optional `glossary` (a term map) and `tone` (`"formal"`, `"informal"`, or `"neutral"`) refine the output. The optional `prune` boolean (off by default) opts in to removing orphaned keys (present in a target file but absent from the source) from the written target files and the lock; the `translate --prune` flag overrides it per run. The optional `generatePlurals` boolean (off by default) opts in to synthesizing the CLDR plural forms a richer target language requires but the source lacks (i18next-JSON projects translated by an LLM provider only; DeepL, non-i18next formats, and unknown languages fall back to the per-locale plural warning and never fail); a per-run `generatePlurals` override on `translate` takes precedence, and generated keys are reported separately from translated keys on the summary. OpenAI and Gemini take `{ model, maxOutputTokens }`; `openai-compatible` additionally takes a `baseUrl` (for a local or self-hosted server such as LM Studio, Ollama, or vLLM) and an optional `apiKeyEnvVar`; DeepL takes `{}` (with an optional `glossaryId`). API keys are never part of the config. Each provider reads its own environment variable (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPL_API_KEY`; `openai-compatible` resolves its key from `apiKeyEnvVar`, then `OPENAI_COMPATIBLE_API_KEY`, then falls back to a keyless placeholder).
75
75
 
76
76
  ## API reference
77
77
 
package/dist/index.cjs CHANGED
@@ -503,16 +503,14 @@ function validateRequest(request) {
503
503
  return parsed.data;
504
504
  }
505
505
  function toIntegrityInputs(entries, values) {
506
- return entries.map((entry) => {
506
+ const inputs = [];
507
+ for (const entry of entries) {
507
508
  const translatedValue = values.get(entry.key);
508
- if (translatedValue === void 0) {
509
- throw new ProviderError(
510
- "INVALID_RESPONSE",
511
- "The provider response is missing one or more keys."
512
- );
509
+ if (translatedValue !== void 0) {
510
+ inputs.push({ key: entry.key, sourceValue: entry.value, translatedValue });
513
511
  }
514
- return { key: entry.key, sourceValue: entry.value, translatedValue };
515
- });
512
+ }
513
+ return inputs;
516
514
  }
517
515
  function toItem(entry) {
518
516
  return {
@@ -544,25 +542,29 @@ function deriveJsonSchema(schema) {
544
542
  }
545
543
  return result;
546
544
  }
547
- function reconcile(translations, requestedKeys) {
548
- const requested = new Set(requestedKeys);
549
- const values = /* @__PURE__ */ new Map();
545
+ function partitionTranslations(translations, requested) {
546
+ const firstSeen = /* @__PURE__ */ new Map();
547
+ const duplicated = /* @__PURE__ */ new Set();
550
548
  for (const { key, value } of translations) {
551
- if (!requested.has(key) || values.has(key)) {
552
- throw new ProviderError(
553
- "INVALID_RESPONSE",
554
- "The provider returned an unexpected or duplicate key."
555
- );
549
+ if (!requested.has(key)) {
550
+ throw new ProviderError("INVALID_RESPONSE", "The provider returned an unexpected key.");
556
551
  }
557
- values.set(key, value);
552
+ if (firstSeen.has(key)) {
553
+ duplicated.add(key);
554
+ continue;
555
+ }
556
+ firstSeen.set(key, value);
558
557
  }
559
- if (values.size !== requested.size) {
560
- throw new ProviderError(
561
- "INVALID_RESPONSE",
562
- "The provider response is missing one or more keys."
563
- );
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);
564
566
  }
565
- return values;
567
+ return { accepted: firstSeen, missingKeys };
566
568
  }
567
569
  function reconcileResult(raw, requestedKeys) {
568
570
  const parsed = translationsResultSchema.safeParse(raw);
@@ -574,22 +576,54 @@ function reconcileResult(raw, requestedKeys) {
574
576
  }
575
577
  return reconcile(parsed.data.translations, requestedKeys);
576
578
  }
579
+ var MAX_REPAIR_ROUNDS = 1;
577
580
  async function runLlmTranslation(request, mechanism) {
578
581
  const data = validateRequest(request);
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
+ }
595
+ const integrity = checkBatchIntegrity(
596
+ toIntegrityInputs(data.entries, values),
597
+ request.extractPlaceholders,
598
+ request.comparePlaceholders
599
+ );
600
+ return usage === void 0 ? { values, integrity, notices: [] } : { values, integrity, usage, notices: [] };
601
+ }
602
+ async function requestTranslations(mechanism, data, signal) {
579
603
  const payloadJson = JSON.stringify(buildDataPayload(data));
580
604
  const requestedKeys = data.entries.map((entry) => entry.key);
581
605
  const completion = await mechanism.translate({
582
606
  payloadJson,
583
607
  requestedKeys,
584
- ...request.signal !== void 0 ? { signal: request.signal } : {}
608
+ ...signal !== void 0 ? { signal } : {}
585
609
  });
586
- const values = reconcileResult(completion.raw, requestedKeys);
587
- const integrity = checkBatchIntegrity(
588
- toIntegrityInputs(data.entries, values),
589
- request.extractPlaceholders,
590
- request.comparePlaceholders
591
- );
592
- return completion.usage === void 0 ? { values, integrity } : { values, integrity, usage: completion.usage };
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
+ };
593
627
  }
594
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.";
595
629
  function assertNotTruncated(truncated) {
@@ -837,7 +871,10 @@ function createDeepLProvider(config, deps = {}) {
837
871
  return {
838
872
  id: PROVIDER_ID2,
839
873
  kind: "machine-translation",
840
- 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,
841
878
  translateBatch: (request) => translate(bundle, validConfig, request)
842
879
  };
843
880
  }
@@ -1141,16 +1178,19 @@ function buildResponseFormat(mode) {
1141
1178
  }
1142
1179
  };
1143
1180
  }
1144
- function buildOpenAiRequest(config, payloadJson, mode = "strict-schema") {
1145
- return {
1146
- model: config.model,
1147
- max_completion_tokens: config.maxOutputTokens,
1181
+ function buildOpenAiRequest(config, payloadJson, mode = "strict-schema", tokenLimitField = "max_completion_tokens") {
1182
+ const base = {
1148
1183
  messages: [
1149
1184
  { role: "system", content: OPENAI_SYSTEM_RULES },
1150
1185
  { role: "user", content: payloadJson }
1151
1186
  ],
1187
+ model: config.model,
1152
1188
  response_format: buildResponseFormat(mode)
1153
1189
  };
1190
+ if (tokenLimitField === "max_tokens") {
1191
+ return { ...base, max_tokens: config.maxOutputTokens };
1192
+ }
1193
+ return { ...base, max_completion_tokens: config.maxOutputTokens };
1154
1194
  }
1155
1195
  function advanceStringScan(char, escaped) {
1156
1196
  if (escaped) {
@@ -1321,7 +1361,7 @@ function createOpenAiCompatibleProvider(config, deps = {}) {
1321
1361
  function createMechanism4(client, config) {
1322
1362
  return {
1323
1363
  translate: async ({ payloadJson, signal }) => {
1324
- const body = buildOpenAiRequest(config, payloadJson, "strict-schema");
1364
+ const body = buildOpenAiRequest(config, payloadJson, "strict-schema", "max_tokens");
1325
1365
  const completion = await callClient5(client, body, signal);
1326
1366
  return extractOpenAiResult(completion, true);
1327
1367
  }
@@ -1922,6 +1962,9 @@ async function atomicWriteFile(path, data, ops = nodeOps) {
1922
1962
  throw error;
1923
1963
  }
1924
1964
  }
1965
+ function stripLeadingBom(content) {
1966
+ return content.charCodeAt(0) === 65279 ? content.slice(1) : content;
1967
+ }
1925
1968
  async function readBoundedUtf82(handle, size) {
1926
1969
  const buffer = Buffer.allocUnsafe(size);
1927
1970
  let offset = 0;
@@ -1932,7 +1975,7 @@ async function readBoundedUtf82(handle, size) {
1932
1975
  }
1933
1976
  offset += bytesRead;
1934
1977
  }
1935
- return buffer.toString("utf8", 0, offset);
1978
+ return stripLeadingBom(buffer.toString("utf8", 0, offset));
1936
1979
  }
1937
1980
  async function readBounded2(filePath) {
1938
1981
  const handle = await promises.open(filePath, "r");
@@ -2975,15 +3018,8 @@ function subBatchFailedNotice(count, error) {
2975
3018
  }
2976
3019
 
2977
3020
  // src/flow/notices.ts
2978
- function isNotice(value) {
2979
- return typeof value === "object" && value !== null && typeof value.code === "string" && typeof value.message === "string";
2980
- }
2981
3021
  function readNotices(result) {
2982
- const candidate = result.notices;
2983
- if (!Array.isArray(candidate)) {
2984
- return [];
2985
- }
2986
- return candidate.filter(isNotice);
3022
+ return result.notices ?? [];
2987
3023
  }
2988
3024
 
2989
3025
  // src/flow/plural-categories.ts
@@ -3187,20 +3223,26 @@ async function runGenerationSubBatch(context, batch, accepted, withheld, provide
3187
3223
  return [subBatchFailedNotice(batch.length, error)];
3188
3224
  }
3189
3225
  for (const item of batch) {
3190
- const value = result.values.get(item.targetKey);
3191
- const integrity = result.integrity.get(item.targetKey);
3192
- if (value !== void 0 && integrity?.matches === true) {
3193
- accepted.push({
3194
- targetKey: item.targetKey,
3195
- entry: { ...syntheticEntry(item), value },
3196
- lockHash: generatedLockHash(item.governingEntries, item.category)
3197
- });
3198
- } else {
3199
- withheld.push(item.targetKey);
3200
- }
3226
+ foldGenerationItem(item, result, accepted, withheld, providerFailures);
3201
3227
  }
3202
3228
  return readNotices(result);
3203
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
+ }
3204
3246
 
3205
3247
  // src/flow/locale-run.ts
3206
3248
  function emptyResource(locale, format) {
@@ -3388,16 +3430,22 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
3388
3430
  return [subBatchFailedNotice(batch.length, error)];
3389
3431
  }
3390
3432
  for (const entry of batch) {
3391
- const value = result.values.get(entry.key);
3392
- const integrity = result.integrity.get(entry.key);
3393
- if (value !== void 0 && integrity?.matches === true) {
3394
- accepted.set(entry.key, { value, source: entry });
3395
- } else {
3396
- integrityMismatches.push(entry.key);
3397
- }
3433
+ foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures);
3398
3434
  }
3399
3435
  return readNotices(result);
3400
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
+ }
3401
3449
  function computeLockEntries(params, merged, withheld, generated) {
3402
3450
  const lockEntries = {};
3403
3451
  const sourceBaseKeys = sourcePluralBaseKeys(params.source);