champollion 0.4.0 → 0.5.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/README.md +4 -4
- package/lib/autofix.js +7 -9
- package/lib/commands/init.js +20 -14
- package/lib/commands/wrap.js +2 -2
- package/lib/config.js +35 -4
- package/lib/content-estimate.js +5 -5
- package/lib/content-refusals.js +27 -13
- package/lib/content-sync.js +59 -51
- package/lib/cost-report.js +5 -5
- package/lib/docusaurus-sync.js +42 -41
- package/lib/fallback.js +157 -19
- package/lib/locale-state.js +4 -0
- package/lib/methods/direct-llm.js +13 -8
- package/lib/methods/gemini.js +1 -1
- package/lib/methods/llm-coached.js +3 -1
- package/lib/methods/llm.js +7 -2
- package/lib/methods/openai.js +3 -1
- package/lib/methods/provider-pricing.js +4 -0
- package/lib/models.js +91 -55
- package/lib/pairs.js +8 -8
- package/lib/segment.js +22 -7
- package/lib/validate.js +118 -6
- package/lib/verify.js +38 -2
- package/package.json +1 -1
- package/shared/licenses.json +99 -3
- package/shared/retired-model-aliases.json +9 -0
- package/shared/schemas/human-services.schema.json +1 -1
- package/shared/schemas/retired-model-aliases.schema.json +32 -0
- package/shared/model-aliases.json +0 -7
- package/shared/schemas/model-aliases.schema.json +0 -27
package/lib/docusaurus-sync.js
CHANGED
|
@@ -74,7 +74,7 @@ import {
|
|
|
74
74
|
import { walkFiles } from './locale-layout.js';
|
|
75
75
|
import {
|
|
76
76
|
readRefusals, contentHolds, nextRefusals, storeRefusals, blockUnit, fieldUnit, describeHeld, describeNewHold,
|
|
77
|
-
describeFallenBack, previewHeld, PAGE_UNIT, PAGE_NAME,
|
|
77
|
+
describeFallenBack, previewHeld, PAGE_UNIT, PAGE_NAME, pendingLockValue, isPendingLock,
|
|
78
78
|
} from './content-refusals.js';
|
|
79
79
|
import { applyNamedKeyRule, reportUnmatchedKeys, unmatchedKeysSummary, reportNamedFromCache } from './named-keys.js';
|
|
80
80
|
|
|
@@ -248,7 +248,9 @@ function scanDocusaurusContentWork(contentSources, pairEntries, config, manifest
|
|
|
248
248
|
action = 'changed';
|
|
249
249
|
}
|
|
250
250
|
} else {
|
|
251
|
-
// No stored hash — check for [EN] fallback markers
|
|
251
|
+
// No stored hash — check for legacy [EN] fallback markers (pages
|
|
252
|
+
// written before 2026-10-05; now such a page has a pending:<hash>
|
|
253
|
+
// lock entry instead). This runs
|
|
252
254
|
// even under --force-content: a target with no lock entry and
|
|
253
255
|
// no [EN] markers is a genuine hand-translated file, and force
|
|
254
256
|
// must never overwrite human work with machine output.
|
|
@@ -1372,6 +1374,9 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1372
1374
|
// Each field is cached on its own source text (exactly like a
|
|
1373
1375
|
// key-value sync key): a title edit re-pays only the title.
|
|
1374
1376
|
const translatedFields = {};
|
|
1377
|
+
// A field left in the source language (refused, or held from an
|
|
1378
|
+
// earlier refusal): the page is written, its lock marked pending.
|
|
1379
|
+
let fieldsLeftInSource = false;
|
|
1375
1380
|
if (hasFrontMatter && Object.keys(fieldsToTranslate).length > 0) {
|
|
1376
1381
|
const { hits: fmHits, misses: fmMisses } = partitionByTM(
|
|
1377
1382
|
tm, fieldsToTranslate, Object.keys(fieldsToTranslate), code, tmKey
|
|
@@ -1393,14 +1398,16 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1393
1398
|
if (Object.keys(fbCache.hits).length > 0) notePage(pairKey, label);
|
|
1394
1399
|
const fieldsToSend = fbCache.misses;
|
|
1395
1400
|
for (const f of Object.keys(translatedFields)) refusal.filled.add(fieldUnit(f));
|
|
1396
|
-
// Refused before: a held field
|
|
1397
|
-
//
|
|
1401
|
+
// Refused before: a held field is not sent again; it keeps its
|
|
1402
|
+
// source text and the page is written (its lock marked pending).
|
|
1398
1403
|
const heldFields = fieldsToSend.filter(f => holds.field(f, fieldsToTranslate[f]) === 'held');
|
|
1399
1404
|
const fallbackOnlyFields = new Set(fieldsToSend.filter(f => holds.field(f, fieldsToTranslate[f]) === 'fallback-only'));
|
|
1400
1405
|
if (heldFields.length > 0) {
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1406
|
+
fieldsLeftInSource = true;
|
|
1407
|
+
const names = heldFields.map(f => `front matter "${f}"`);
|
|
1408
|
+
refusal.held.push(...names);
|
|
1409
|
+
output.warn(describeHeld({ file: label, code, pairKey, pairConfig, names }));
|
|
1410
|
+
for (const f of heldFields) fieldsToSend.splice(fieldsToSend.indexOf(f), 1);
|
|
1404
1411
|
}
|
|
1405
1412
|
|
|
1406
1413
|
if (fieldsToSend.length > 0) {
|
|
@@ -1420,9 +1427,9 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1420
1427
|
budget: fallbackBudget,
|
|
1421
1428
|
sharedOutputs: sharedOutputsFor(code),
|
|
1422
1429
|
label: `${docLabel} front matter`,
|
|
1423
|
-
translate: (keys, cfg) => translateBatch(
|
|
1430
|
+
translate: (keys, cfg, extra = {}) => translateBatch(
|
|
1424
1431
|
keys, fieldsToTranslate, cfg,
|
|
1425
|
-
{ apiKey, model: cfg.model, batchSize: cfg.batchSize || 30 },
|
|
1432
|
+
{ apiKey, model: cfg.model, batchSize: cfg.batchSize || 30, ...extra },
|
|
1426
1433
|
),
|
|
1427
1434
|
fallbackOnly: fallbackOnlyFields,
|
|
1428
1435
|
});
|
|
@@ -1432,18 +1439,16 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1432
1439
|
refusal.refused.push({ unit: fieldUnit(field), source: fieldsToTranslate[field], methods });
|
|
1433
1440
|
}
|
|
1434
1441
|
if (fm.hollowed.length > 0) {
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
);
|
|
1445
|
-
err.heldNext = true;
|
|
1446
|
-
throw err;
|
|
1442
|
+
// Refused, and refused again when asked with the reason: the
|
|
1443
|
+
// field keeps its source text; the page is still written.
|
|
1444
|
+
fieldsLeftInSource = true;
|
|
1445
|
+
for (const h of fm.hollowed) {
|
|
1446
|
+
output.warn(
|
|
1447
|
+
`${label} → ${code}: front matter "${h.field}" kept in the source language — ${h.reason}`
|
|
1448
|
+
+ `${h.fallbackReason ? `; the fallback (${pairConfig.fallback.method}): ${h.fallbackReason}` : ''}.`
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
output.warn(describeNewHold({ file: label, pairKey, pairConfig, count: fm.hollowed.length }));
|
|
1447
1452
|
}
|
|
1448
1453
|
if (fm.noResults) {
|
|
1449
1454
|
// Front matter translation failed — loud error
|
|
@@ -1461,9 +1466,10 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1461
1466
|
// by the pair's method — the page is held, as with no fallback.
|
|
1462
1467
|
const unfilled = [...fallbackOnlyFields].filter(f => !(f in fm.translated));
|
|
1463
1468
|
if (unfilled.length > 0) {
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1469
|
+
fieldsLeftInSource = true;
|
|
1470
|
+
const names = unfilled.map(f => `front matter "${f}"`);
|
|
1471
|
+
refusal.held.push(...names);
|
|
1472
|
+
output.warn(describeHeld({ file: label, code, pairKey, pairConfig, names }));
|
|
1467
1473
|
}
|
|
1468
1474
|
Object.assign(translatedFields, fm.translated);
|
|
1469
1475
|
for (const f of Object.keys(fm.translated)) refusal.filled.add(fieldUnit(f));
|
|
@@ -1583,7 +1589,7 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1583
1589
|
}));
|
|
1584
1590
|
|
|
1585
1591
|
const missed = [];
|
|
1586
|
-
const heldBlocks = []; // refused before: not sent,
|
|
1592
|
+
const heldBlocks = []; // refused before: not sent, the source text kept
|
|
1587
1593
|
let position = 0; // place among the translatable blocks
|
|
1588
1594
|
for (const r of rendered) {
|
|
1589
1595
|
if (r.seg.type !== 'translatable') {
|
|
@@ -1603,8 +1609,8 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1603
1609
|
const hold = holds.block(r.source);
|
|
1604
1610
|
if (hold === 'held') {
|
|
1605
1611
|
// Refused before by this method (and its fallback): the
|
|
1606
|
-
//
|
|
1607
|
-
r.out =
|
|
1612
|
+
// source text stays, nothing is sent or billed.
|
|
1613
|
+
r.out = r.source;
|
|
1608
1614
|
heldBlocks.push(r);
|
|
1609
1615
|
continue;
|
|
1610
1616
|
}
|
|
@@ -1615,7 +1621,7 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1615
1621
|
bodyUsedFallback = true;
|
|
1616
1622
|
const names = heldBlocks.map(r => `paragraph ${r.pos + 1}`);
|
|
1617
1623
|
refusal.held.push(...names);
|
|
1618
|
-
output.warn(describeHeld({ file: label, code, pairKey, pairConfig, names
|
|
1624
|
+
output.warn(describeHeld({ file: label, code, pairKey, pairConfig, names }));
|
|
1619
1625
|
}
|
|
1620
1626
|
|
|
1621
1627
|
if (missed.length > 0) {
|
|
@@ -1624,14 +1630,13 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1624
1630
|
// batch → one missing-segments-only retry → (with a
|
|
1625
1631
|
// fallback method: one batch through it for every block the
|
|
1626
1632
|
// primary dropped or damaged — lib/fallback.js) → honest
|
|
1627
|
-
//
|
|
1633
|
+
// the source text, unmarked, for anything still missing. A
|
|
1628
1634
|
// duplicate/unknown marker or an empty first response
|
|
1629
1635
|
// still fails the file whole when no fallback rescues it.
|
|
1630
1636
|
const outcome = await translateBlocksWithFallback({
|
|
1631
1637
|
missed,
|
|
1632
1638
|
blocks,
|
|
1633
1639
|
pairConfig,
|
|
1634
|
-
fallbackPrefix: config.fallbackPrefix,
|
|
1635
1640
|
budget: fallbackBudget,
|
|
1636
1641
|
sharedOutputs: sharedOutputsFor(code),
|
|
1637
1642
|
label: docLabel,
|
|
@@ -1639,7 +1644,6 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1639
1644
|
texts,
|
|
1640
1645
|
buildPrompt: (t) => buildBlockBatchPrompt(t, cfg, { ...promptOptions, pageTitle }),
|
|
1641
1646
|
callModel: (prompt) => translateRawContent(prompt, { apiKey, pairConfig: cfg }),
|
|
1642
|
-
fallbackPrefix: config.fallbackPrefix,
|
|
1643
1647
|
}),
|
|
1644
1648
|
fallbackOnly: new Set(missed.flatMap((r, i) => (r.fallbackOnly ? [i] : []))),
|
|
1645
1649
|
});
|
|
@@ -1664,7 +1668,7 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1664
1668
|
}
|
|
1665
1669
|
if ((outcome.refused || []).length > 0) {
|
|
1666
1670
|
output.warn(
|
|
1667
|
-
`Docusaurus body for ${code}: ${outcome.refused.length} block(s) of ${dirName}/${relPath} refused by the quality gate — `
|
|
1671
|
+
`Docusaurus body for ${code}: ${outcome.refused.length} block(s) of ${dirName}/${relPath} refused by the quality gate, also when asked again with the reason — `
|
|
1668
1672
|
+ outcome.refused.slice(0, 3).map(r => `paragraph ${r.block}: ${r.reason}`).join('; ')
|
|
1669
1673
|
+ `${outcome.refused.length > 3 ? '; …' : ''}.`
|
|
1670
1674
|
);
|
|
@@ -1680,8 +1684,7 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1680
1684
|
: 'missing from the model response after a retry';
|
|
1681
1685
|
output.warn(
|
|
1682
1686
|
`Docusaurus body for ${code}: ${fellBack.length} of ${missed.length} ` +
|
|
1683
|
-
`block(s) ${why} —
|
|
1684
|
-
`'${config.fallbackPrefix}'-prefixed source. ` +
|
|
1687
|
+
`block(s) ${why} — left in the source language, unmarked (\`champollion status\` lists them). ` +
|
|
1685
1688
|
describeFallenBack({
|
|
1686
1689
|
file: label, pairKey, pairConfig, fellBack: fellBack.length, newlyHeld, refusedBy: outcome.refusedBy,
|
|
1687
1690
|
})
|
|
@@ -1721,7 +1724,7 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1721
1724
|
|
|
1722
1725
|
// Content-preservation check on the REASSEMBLED body — same
|
|
1723
1726
|
// lane, same reasoning as the front matter above. Skipped for a
|
|
1724
|
-
// fallback body: it deliberately carries
|
|
1727
|
+
// fallback body: it deliberately carries untranslated
|
|
1725
1728
|
// source text and is neither cached nor lock-advanced already.
|
|
1726
1729
|
const bodyHollowed = !bodyUsedFallback && checkContentPreservation(body, translatedBody);
|
|
1727
1730
|
if (bodyHollowed) {
|
|
@@ -1736,7 +1739,7 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1736
1739
|
// Store per-block AND whole-body entries only after the check
|
|
1737
1740
|
// passes (whole-body makes reverts/lock-loss re-runs free). A
|
|
1738
1741
|
// fallback body is NEVER stored whole — it contains
|
|
1739
|
-
// untranslated
|
|
1742
|
+
// untranslated source text.
|
|
1740
1743
|
for (const s of pendingBlockStores) {
|
|
1741
1744
|
storeTM(tm, s.source, code, s.tmKey, s.translation);
|
|
1742
1745
|
}
|
|
@@ -1759,10 +1762,9 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1759
1762
|
// keeps its old entry (or none), so it re-fires next sync — as
|
|
1760
1763
|
// does a fallback body (its re-fire is TM-cheap: only the
|
|
1761
1764
|
// fallen-back segment re-bills).
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
}
|
|
1765
|
+
updatedDocuManifest[manifestKey] = !bodyUsedFallback && !fieldsLeftInSource
|
|
1766
|
+
? sourceHash : pendingLockValue(sourceHash);
|
|
1767
|
+
manifestDirty = true;
|
|
1766
1768
|
|
|
1767
1769
|
} catch (contentErr) {
|
|
1768
1770
|
if (contentErr.heldNames) {
|
|
@@ -1774,7 +1776,6 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1774
1776
|
settleRefusals();
|
|
1775
1777
|
output.warn(describeHeld({
|
|
1776
1778
|
file: `${dirName}/${relPath}`, code, pairKey, pairConfig, names: contentErr.heldNames, pageHeld: true,
|
|
1777
|
-
fallbackPrefix: config.fallbackPrefix,
|
|
1778
1779
|
}));
|
|
1779
1780
|
completed++;
|
|
1780
1781
|
output.raw(` [${completed}/${totalWork}] ${dirName}/${relPath} → ${code} [HELD]`);
|
package/lib/fallback.js
CHANGED
|
@@ -27,9 +27,9 @@ import { estimateCost } from './pairs.js';
|
|
|
27
27
|
import { lookupTM, lookupTMValidated, tmMethodKey } from './tm.js';
|
|
28
28
|
import { createTMEvictor } from './tm-evict.js';
|
|
29
29
|
import { translatableBlockSources } from './content-estimate.js';
|
|
30
|
-
import { checkContentPreservation, contentGateFault, sharedOutputReason } from './validate.js';
|
|
30
|
+
import { checkContentPreservation, contentGateFault, sharedOutputReason, isKeepAsWrittenFault } from './validate.js';
|
|
31
31
|
import { restoreBlocks, hasOrphanedPlaceholders, PLACEHOLDER_PREFIX, PLACEHOLDER_SUFFIX } from './content.js';
|
|
32
|
-
import {
|
|
32
|
+
import { contentKeyUnits } from './config.js';
|
|
33
33
|
import { output } from './output.js';
|
|
34
34
|
import { refusalCategory, notePrimaryReason } from './refusal-category.js';
|
|
35
35
|
|
|
@@ -125,8 +125,8 @@ export function createFallbackBudget({ maxCost = null, committed = 0, cwd = null
|
|
|
125
125
|
* @param {number} chars
|
|
126
126
|
* @returns {number}
|
|
127
127
|
*/
|
|
128
|
-
export function charsToKeyUnits(chars) {
|
|
129
|
-
return
|
|
128
|
+
export function charsToKeyUnits(chars, pairConfig = {}) {
|
|
129
|
+
return contentKeyUnits(chars, pairConfig);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
// ── Reporting ────────────────────────────────────────────────────────
|
|
@@ -546,6 +546,45 @@ export async function translateFieldsWithFallback({
|
|
|
546
546
|
stores.push({ text: fields[field], value, tmKey: primaryKey });
|
|
547
547
|
}
|
|
548
548
|
remember(passing.filter(([field]) => !shared.has(field)));
|
|
549
|
+
|
|
550
|
+
// Refused: asked once more, with the reason (the key-value lane's
|
|
551
|
+
// feedback retry, and retryRefusedBlocks for blocks). A keep-as-written
|
|
552
|
+
// refusal answered the same way twice is taken at its word.
|
|
553
|
+
const refusedFields = [...failed].filter(([, f]) => f.reason && !f.fallbackOnly).map(([field]) => field);
|
|
554
|
+
if (refusedFields.length > 0) {
|
|
555
|
+
const descriptions = {};
|
|
556
|
+
for (const field of refusedFields) {
|
|
557
|
+
const f = failed.get(field);
|
|
558
|
+
descriptions[field] = `RETRY: a previous translation ("${String(f.value ?? '').slice(0, 60)}") was refused by an automatic quality check: ${f.reason}. `
|
|
559
|
+
+ `Translate this ${field} into ${pairConfig.name || pairConfig.target}. If it is correct exactly as written — a name, title, acronym or code — return it unchanged.`;
|
|
560
|
+
}
|
|
561
|
+
const chars = refusedFields.reduce((n, f) => n + fields[f].length, 0);
|
|
562
|
+
const deaf = pairConfig.acceptsInstructions === false;
|
|
563
|
+
const verdict = deaf ? { ok: false } : budget ? await budget.approve(charsToKeyUnits(chars, pairConfig), pairConfig) : { ok: true };
|
|
564
|
+
// Declared deaf: not asked again; its first answer is judged as a second one.
|
|
565
|
+
let again = deaf ? Object.fromEntries(refusedFields.map(f => [f, failed.get(f).value])) : null;
|
|
566
|
+
if (verdict.ok) {
|
|
567
|
+
try { again = await translate(refusedFields, pairConfig, { descriptions }); } catch { again = null; }
|
|
568
|
+
}
|
|
569
|
+
const againPassing = [];
|
|
570
|
+
for (const field of refusedFields) {
|
|
571
|
+
const value = again?.[field];
|
|
572
|
+
if (typeof value !== 'string') continue;
|
|
573
|
+
const f = failed.get(field);
|
|
574
|
+
const fault = contentGateFault(fields[field], value, pairConfig);
|
|
575
|
+
const insisted = isKeepAsWrittenFault(fault) && isKeepAsWrittenFault(f.reason) && value === f.value;
|
|
576
|
+
if (!fault || insisted) againPassing.push([field, value]);
|
|
577
|
+
else f.reason = `${f.reason}; asked again with that reason: ${fault}`;
|
|
578
|
+
}
|
|
579
|
+
const againShared = sharedCheck(againPassing);
|
|
580
|
+
for (const [field, value] of againPassing) {
|
|
581
|
+
if (againShared.has(field)) continue;
|
|
582
|
+
translated[field] = value;
|
|
583
|
+
stores.push({ text: fields[field], value, tmKey: primaryKey });
|
|
584
|
+
failed.delete(field);
|
|
585
|
+
}
|
|
586
|
+
remember(againPassing.filter(([field]) => !againShared.has(field)));
|
|
587
|
+
}
|
|
549
588
|
} else if (fb) {
|
|
550
589
|
for (const field of primaryAsked) failed.set(field, { reason: null });
|
|
551
590
|
}
|
|
@@ -563,7 +602,7 @@ export async function translateFieldsWithFallback({
|
|
|
563
602
|
notePrimaryReason(report, refusalCategory(f.reason, { sharedOutput: !!f.sharedOutput, heldBefore: !!f.fallbackOnly, noAnswer: !f.reason }));
|
|
564
603
|
}
|
|
565
604
|
const chars = asked.reduce((n, f) => n + fields[f].length, 0);
|
|
566
|
-
const verdict = budget ? await budget.approve(charsToKeyUnits(chars), fb) : { ok: true };
|
|
605
|
+
const verdict = budget ? await budget.approve(charsToKeyUnits(chars, fb), fb) : { ok: true };
|
|
567
606
|
if (!verdict.ok) {
|
|
568
607
|
report.skipped = { reason: verdict.reason, items: asked.map(f => `front matter "${f}"`) };
|
|
569
608
|
} else {
|
|
@@ -659,12 +698,73 @@ export function blockFault(protectedSource, protectedOut, restoredOut, restoredS
|
|
|
659
698
|
return contentGateFault(restoredSource, restoredOut, pairConfig);
|
|
660
699
|
}
|
|
661
700
|
|
|
701
|
+
/**
|
|
702
|
+
* Ask the pair's method once more for the blocks the gate refused, telling it
|
|
703
|
+
* why. WHY: a refusal used to be final for the run, and the block was left in
|
|
704
|
+
* the source language — while a good share of refusals are the model keeping
|
|
705
|
+
* text that is correct as written (a name heading, a citation, a table of
|
|
706
|
+
* codes), which no fixed rule can tell from a missed translation (dogfood
|
|
707
|
+
* 2026-10-05: 0.5% of 40,352 accepted blocks of champollion.dev). Told the
|
|
708
|
+
* reason, the model either translates it or returns it unchanged again — and
|
|
709
|
+
* a keep-as-written refusal (isKeepAsWrittenFault) answered the same way
|
|
710
|
+
* twice is taken at its word, as the key-value lane takes a name. Every other
|
|
711
|
+
* fault must clear the gate outright. One ask, never a loop; under --max-cost
|
|
712
|
+
* it is billed like any other call (budget.approve).
|
|
713
|
+
*
|
|
714
|
+
* @param {object} p
|
|
715
|
+
* @param {number[]} p.idx - Indices (into `missed`) the gate refused
|
|
716
|
+
* @param {string[]} p.texts - The protected block texts
|
|
717
|
+
* @param {Array<{source: string}>} p.missed
|
|
718
|
+
* @param {Map<string,string>} p.blocks - protectBlocks() map
|
|
719
|
+
* @param {object} p.pairConfig
|
|
720
|
+
* @param {Function} p.runBatch
|
|
721
|
+
* @param {Map<number,string>} p.firstOut - The refused (restored) answers
|
|
722
|
+
* @param {Map<number,string>} p.reasons - Why each was refused; a block asked
|
|
723
|
+
* again and refused again gets the second reason appended
|
|
724
|
+
* @param {object|null} [p.budget]
|
|
725
|
+
* @returns {Promise<Map<number,string>>} index → accepted (restored) answer
|
|
726
|
+
*/
|
|
727
|
+
async function retryRefusedBlocks({ idx, texts, missed, blocks, pairConfig, runBatch, firstOut, reasons, budget = null }) {
|
|
728
|
+
const accepted = new Map();
|
|
729
|
+
if (idx.length === 0) return accepted;
|
|
730
|
+
// An endpoint that declares it follows no instructions (a trained NMT
|
|
731
|
+
// model) would answer the same: not asked again — its first answers are
|
|
732
|
+
// judged as second answers are (the key-value lane's rule).
|
|
733
|
+
if (pairConfig.acceptsInstructions === false) {
|
|
734
|
+
for (const i of idx) if (isKeepAsWrittenFault(reasons.get(i))) accepted.set(i, firstOut.get(i));
|
|
735
|
+
return accepted;
|
|
736
|
+
}
|
|
737
|
+
if (budget) {
|
|
738
|
+
const chars = idx.reduce((n, i) => n + missed[i].source.length, 0);
|
|
739
|
+
const verdict = await budget.approve(charsToKeyUnits(chars, pairConfig), pairConfig);
|
|
740
|
+
if (!verdict.ok) return accepted;
|
|
741
|
+
}
|
|
742
|
+
const retryNotes = new Map(idx.map(i => [texts[i], reasons.get(i)]));
|
|
743
|
+
let res = null;
|
|
744
|
+
try {
|
|
745
|
+
res = await runBatch(idx.map(i => texts[i]), { ...pairConfig, retryNotes });
|
|
746
|
+
} catch {
|
|
747
|
+
return accepted;
|
|
748
|
+
}
|
|
749
|
+
const fell = new Set(res?.fellBack || []);
|
|
750
|
+
idx.forEach((i, j) => {
|
|
751
|
+
if (!res || fell.has(j) || typeof res.blocks[j] !== 'string') return;
|
|
752
|
+
const restored = restoreBlocks(res.blocks[j], blocks);
|
|
753
|
+
const fault = blockFault(texts[i], res.blocks[j], restored, missed[i].source, pairConfig);
|
|
754
|
+
const insisted = isKeepAsWrittenFault(fault) && isKeepAsWrittenFault(reasons.get(i)) && restored === firstOut.get(i);
|
|
755
|
+
if (!fault || insisted) accepted.set(i, restored);
|
|
756
|
+
else reasons.set(i, `${reasons.get(i)}; asked again with that reason: ${fault}`);
|
|
757
|
+
});
|
|
758
|
+
return accepted;
|
|
759
|
+
}
|
|
760
|
+
|
|
662
761
|
/**
|
|
663
762
|
* Translate the Markdown blocks the TM ladder did not hold: one batch through
|
|
664
763
|
* the pair's method, then one batch through its fallback for every block the
|
|
665
764
|
* primary dropped or damaged. A block both fail is written as
|
|
666
|
-
*
|
|
667
|
-
*
|
|
765
|
+
* its source text, unmarked (never cached; the caller records the refusal,
|
|
766
|
+
* does not advance the file's lock, and reports it). A refused block is first
|
|
767
|
+
* asked again once, with the reason (retryRefusedBlocks).
|
|
668
768
|
*
|
|
669
769
|
* Without a fallback this is exactly the lanes' old block path: the primary's
|
|
670
770
|
* output stands, its dropped blocks carry the prefix, and a structural
|
|
@@ -686,16 +786,16 @@ export function blockFault(protectedSource, protectedOut, restoredOut, restoredS
|
|
|
686
786
|
* @param {import('./validate.js').SharedOutputIndex|null} [p.sharedOutputs] - The
|
|
687
787
|
* locale's different-inputs-same-output index: a block answered with the
|
|
688
788
|
* text the model gave for other source strings is refused like a damaged
|
|
689
|
-
* one (the fallback gets it; without one, the
|
|
789
|
+
* one (the fallback gets it; without one, the source text stands)
|
|
690
790
|
* @param {string} [p.label] - Names the blocks in that index ("posts/a.md")
|
|
691
791
|
* @param {Set<number>} [p.fallbackOnly] - Indices (into `missed`) of blocks the
|
|
692
792
|
* pair's method refused before (lib/content-refusals.js): not sent to it,
|
|
693
|
-
* only to the fallback (
|
|
793
|
+
* only to the fallback (the source text stands when the fallback does not translate them)
|
|
694
794
|
* @returns {Promise<{ outs: string[], stores: Array<{source: string, translation: string, tmKey: string}>,
|
|
695
795
|
* fellBack: number[], fromFallback: number, report: FallbackReport|null, sharedOutput: number[],
|
|
696
796
|
* refused: Array<{ i: number, block: number, reason: string }>, refusedBy: string[][] }>} refused: blocks written as the
|
|
697
|
-
*
|
|
698
|
-
* refusedBy[i]: the method keys whose answer for block i the gate refused, for blocks left
|
|
797
|
+
* source text because the gate refused them (block = place among the page's blocks, from 1);
|
|
798
|
+
* refusedBy[i]: the method keys whose answer for block i the gate refused, for blocks left in the source language
|
|
699
799
|
* (empty for the rest) — the lane remembers them
|
|
700
800
|
*/
|
|
701
801
|
export async function translateBlocksWithFallback({
|
|
@@ -728,7 +828,7 @@ export async function translateBlocksWithFallback({
|
|
|
728
828
|
};
|
|
729
829
|
// Why the gate refused a block (index → reason), for the lane's warning.
|
|
730
830
|
const refusedReason = new Map();
|
|
731
|
-
/** Blocks
|
|
831
|
+
/** Blocks left as their source text because the gate refused them. */
|
|
732
832
|
const refusedList = (fellBack) => fellBack.filter(i => refusedReason.has(i))
|
|
733
833
|
.map(i => ({ i, block: (Number.isInteger(missed[i].pos) ? missed[i].pos : i) + 1, reason: refusedReason.get(i) }));
|
|
734
834
|
|
|
@@ -744,12 +844,27 @@ export async function translateBlocksWithFallback({
|
|
|
744
844
|
if (fault) refusedReason.set(i, fault);
|
|
745
845
|
}
|
|
746
846
|
const shared = sharedFault(outs.map((out, i) => ({ i, out })).filter(({ i }) => !fellSet.has(i) && !refusedReason.has(i)));
|
|
847
|
+
for (const i of shared) refusedReason.set(i, 'the same text the model gave for other, different source strings');
|
|
848
|
+
// Refused: asked once more, with the reason.
|
|
849
|
+
const again = await retryRefusedBlocks({
|
|
850
|
+
idx: [...refusedReason.keys()], texts, missed, blocks, pairConfig, runBatch,
|
|
851
|
+
firstOut: new Map([...refusedReason.keys()].map(i => [i, outs[i]])), reasons: refusedReason, budget,
|
|
852
|
+
});
|
|
853
|
+
const againShared = sharedFault([...again].map(([i, out]) => ({ i, out })));
|
|
854
|
+
for (const [i, out] of again) {
|
|
855
|
+
if (againShared.has(i)) continue;
|
|
856
|
+
outs[i] = out;
|
|
857
|
+
refusedReason.delete(i);
|
|
858
|
+
shared.delete(i);
|
|
859
|
+
}
|
|
860
|
+
// A memorized answer is reported as one (sharedOutput), not as a refusal.
|
|
861
|
+
for (const i of shared) refusedReason.delete(i);
|
|
747
862
|
const stores = [];
|
|
748
863
|
const accepted = [];
|
|
749
864
|
outs.forEach((out, i) => {
|
|
750
865
|
if (shared.has(i) || refusedReason.has(i)) {
|
|
751
|
-
// Refused: the
|
|
752
|
-
outs[i] =
|
|
866
|
+
// Refused twice: the source text stands, never cached.
|
|
867
|
+
outs[i] = missed[i].source;
|
|
753
868
|
fellBack.push(i);
|
|
754
869
|
primaryRefused.add(i);
|
|
755
870
|
return;
|
|
@@ -812,6 +927,29 @@ export async function translateBlocksWithFallback({
|
|
|
812
927
|
stores.push({ source: missed[i].source, translation: out, tmKey: primaryKey });
|
|
813
928
|
}
|
|
814
929
|
remember(good.filter(({ i }) => !shared.has(i)));
|
|
930
|
+
// Refused (not dropped, not held before): asked once more, with the reason.
|
|
931
|
+
const firstOut = new Map();
|
|
932
|
+
for (const i of failed) {
|
|
933
|
+
if (sharedIdx.has(i)) refusedReason.set(i, 'the same text the model gave for other, different source strings');
|
|
934
|
+
if (refusedReason.has(i)) firstOut.set(i, restoreBlocks(primary.blocks[i], blocks));
|
|
935
|
+
}
|
|
936
|
+
const again = await retryRefusedBlocks({
|
|
937
|
+
idx: [...firstOut.keys()], texts, missed, blocks, pairConfig, runBatch, firstOut, reasons: refusedReason, budget,
|
|
938
|
+
});
|
|
939
|
+
const againShared = sharedFault([...again].map(([i, out]) => ({ i, out })));
|
|
940
|
+
const recovered = new Set();
|
|
941
|
+
for (const [i, out] of again) {
|
|
942
|
+
if (againShared.has(i)) continue;
|
|
943
|
+
outs[i] = out;
|
|
944
|
+
stores.push({ source: missed[i].source, translation: out, tmKey: primaryKey });
|
|
945
|
+
refusedReason.delete(i);
|
|
946
|
+
sharedIdx.delete(i);
|
|
947
|
+
primaryRefused.delete(i);
|
|
948
|
+
recovered.add(i);
|
|
949
|
+
}
|
|
950
|
+
remember([...again].filter(([i]) => recovered.has(i)).map(([i, out]) => ({ i, out })));
|
|
951
|
+
if (recovered.size > 0) failed.splice(0, failed.length, ...failed.filter(i => !recovered.has(i)));
|
|
952
|
+
for (const i of sharedIdx) refusedReason.delete(i);
|
|
815
953
|
} else {
|
|
816
954
|
for (let i = 0; i < texts.length; i++) failed.push(i);
|
|
817
955
|
}
|
|
@@ -831,7 +969,7 @@ export async function translateBlocksWithFallback({
|
|
|
831
969
|
if (failed.length === 0) return { outs, stores, fellBack, fromFallback: 0, report, sharedOutput: [], refused: [], refusedBy: refusedByOf([]) };
|
|
832
970
|
|
|
833
971
|
const chars = failed.reduce((n, i) => n + missed[i].source.length, 0);
|
|
834
|
-
const verdict = budget ? await budget.approve(charsToKeyUnits(chars), fb) : { ok: true };
|
|
972
|
+
const verdict = budget ? await budget.approve(charsToKeyUnits(chars, fb), fb) : { ok: true };
|
|
835
973
|
if (!verdict.ok) {
|
|
836
974
|
report.skipped = { reason: verdict.reason, items: failed.map(i => `block ${i + 1} of ${texts.length}`) };
|
|
837
975
|
if (primaryError) throw primaryError;
|
|
@@ -841,7 +979,7 @@ export async function translateBlocksWithFallback({
|
|
|
841
979
|
for (const i of failed) {
|
|
842
980
|
if (sharedIdx.has(i) || primaryFell.has(i) || refusedReason.has(i) || fallbackOnly.has(i)) {
|
|
843
981
|
outs[i] = sharedIdx.has(i) || refusedReason.has(i) || fallbackOnly.has(i)
|
|
844
|
-
?
|
|
982
|
+
? missed[i].source : restoreBlocks(primary.blocks[i], blocks);
|
|
845
983
|
fellBack.push(i);
|
|
846
984
|
continue;
|
|
847
985
|
}
|
|
@@ -893,8 +1031,8 @@ export async function translateBlocksWithFallback({
|
|
|
893
1031
|
fromFallback++;
|
|
894
1032
|
continue;
|
|
895
1033
|
}
|
|
896
|
-
// Both methods failed this block: the
|
|
897
|
-
outs[i] =
|
|
1034
|
+
// Both methods failed this block: the source text stands.
|
|
1035
|
+
outs[i] = missed[i].source;
|
|
898
1036
|
fellBack.push(i);
|
|
899
1037
|
}
|
|
900
1038
|
return {
|
|
@@ -947,7 +1085,7 @@ export async function translatePageWithFallback({ body, blocks, pairConfig, runP
|
|
|
947
1085
|
? refusalCategory(null, { heldBefore: true })
|
|
948
1086
|
: refusalCategory(primaryBody === null ? null : 'content lost or changed', { noAnswer: primaryBody === null }));
|
|
949
1087
|
|
|
950
|
-
const verdict = budget ? await budget.approve(charsToKeyUnits(body.length), fb) : { ok: true };
|
|
1088
|
+
const verdict = budget ? await budget.approve(charsToKeyUnits(body.length, fb), fb) : { ok: true };
|
|
951
1089
|
if (!verdict.ok) {
|
|
952
1090
|
report.skipped = { reason: verdict.reason, items: ['the page body'] };
|
|
953
1091
|
return { body: null, primaryBody, tmKey: null, report, refusedBy };
|
package/lib/locale-state.js
CHANGED
|
@@ -65,6 +65,7 @@ import path from 'node:path';
|
|
|
65
65
|
import { hashValue } from './hash.js';
|
|
66
66
|
import { tmTranslationsOf, tmTranslationSet, tmMethodKey } from './tm.js';
|
|
67
67
|
import { tmProofTextsFor } from './tm-evict.js';
|
|
68
|
+
import { GATE_VERSION } from './validate.js';
|
|
68
69
|
|
|
69
70
|
/** Where replaced hand edits are recorded: the project root, tracked in git. */
|
|
70
71
|
export const REPLACED_EDITS_FILENAME = '.champollion-replaced-edits.jsonl';
|
|
@@ -269,6 +270,8 @@ export function refusedBy(record, sourceValue, methodKey) {
|
|
|
269
270
|
* @returns {'send'|'fallback-only'|'held'}
|
|
270
271
|
*/
|
|
271
272
|
export function holdState(record, sourceValue, pairConfig) {
|
|
273
|
+
// A refusal by an earlier gate lifts by itself (validate.js GATE_VERSION).
|
|
274
|
+
if (!record || record.gate !== GATE_VERSION) return 'send';
|
|
272
275
|
if (typeof sourceValue !== 'string' || !refusedBy(record, sourceValue, tmMethodKey(pairConfig))) return 'send';
|
|
273
276
|
if (pairConfig.fallback && !refusedBy(record, sourceValue, tmMethodKey(pairConfig.fallback))) return 'fallback-only';
|
|
274
277
|
return 'held';
|
|
@@ -294,6 +297,7 @@ export function recordRefusal(localeState, lockKey, sourceValue, methods, { redo
|
|
|
294
297
|
source: src,
|
|
295
298
|
methods: [...new Set([...known, ...methods])],
|
|
296
299
|
on: new Date().toISOString().slice(0, 10),
|
|
300
|
+
gate: GATE_VERSION,
|
|
297
301
|
...(redo && { redo: true }),
|
|
298
302
|
};
|
|
299
303
|
}
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* set of abstract methods to provide provider-specific HTTP details.
|
|
28
28
|
*
|
|
29
29
|
* RUNTIME MODEL VALIDATION:
|
|
30
|
-
* An OpenRouter-style id ("openai/gpt-5.5"
|
|
30
|
+
* An OpenRouter-style id ("openai/gpt-5.5") becomes
|
|
31
31
|
* the provider's own name, or is refused when the provider has none
|
|
32
32
|
* (resolveModelId — pairs.js applies it when the pair graph is built).
|
|
33
33
|
* On first translate() call, the base class fetches the provider's available
|
|
@@ -46,7 +46,7 @@ import path from 'node:path';
|
|
|
46
46
|
import { LLMMethod, buildSystemMessage, buildUserMessage, promptSettingsFor, isUnsafeKey } from './llm.js';
|
|
47
47
|
import { captureRequest, isCapturing, PREVIEW_KEY } from './request-capture.js';
|
|
48
48
|
import { projectGlossary } from './coaching-data.js';
|
|
49
|
-
import {
|
|
49
|
+
import { requireExactModelId } from '../models.js';
|
|
50
50
|
import { getEnvOrFileVar, findEnvOrFileVar } from '../api-key.js';
|
|
51
51
|
import {
|
|
52
52
|
MAX_RETRIES,
|
|
@@ -330,8 +330,10 @@ class DirectLLMMethod extends LLMMethod {
|
|
|
330
330
|
|
|
331
331
|
/**
|
|
332
332
|
* The model id this provider is sent, for a configured model:
|
|
333
|
-
* -
|
|
334
|
-
* (
|
|
333
|
+
* - a retired alias ("gpt") or a floating id ("…-latest", "~vendor/…")
|
|
334
|
+
* is REFUSED first, on every transport (gateway and local included),
|
|
335
|
+
* naming the exact slug to write — founder ruling 2026-10-05: exact
|
|
336
|
+
* slugs only, no aliasing (lib/models.js requireExactModelId);
|
|
335
337
|
* - an OpenRouter-style id of THIS provider's vendor becomes its own
|
|
336
338
|
* name ("openai/gpt-5.5" → "gpt-5.5", "anthropic/claude-haiku-4.5" →
|
|
337
339
|
* "claude-haiku-4-5") — mirroring the harness's direct providers;
|
|
@@ -345,12 +347,15 @@ class DirectLLMMethod extends LLMMethod {
|
|
|
345
347
|
* ("from --model", "from the top-level \"model\""); cwd: the project
|
|
346
348
|
* directory (its .env may point the method at a gateway)
|
|
347
349
|
* @returns {string|null}
|
|
348
|
-
* @throws {Error} code CHAMPOLLION_MODEL_ROUTE when there is no such model here
|
|
350
|
+
* @throws {Error} code CHAMPOLLION_MODEL_ROUTE when there is no such model here,
|
|
351
|
+
* code CHAMPOLLION_MODEL_ID for a retired alias or a floating id
|
|
349
352
|
*/
|
|
350
353
|
resolveModelId(model, { from = null, cwd = null } = {}) {
|
|
351
|
-
if (!model || typeof model !== 'string'
|
|
354
|
+
if (!model || typeof model !== 'string') return model;
|
|
355
|
+
requireExactModelId(model, { from });
|
|
356
|
+
if (!this._callsOwnApi(cwd)) return model;
|
|
352
357
|
const vendor = this._getModelVendor();
|
|
353
|
-
const resolved =
|
|
358
|
+
const resolved = model;
|
|
354
359
|
const slash = resolved.indexOf('/');
|
|
355
360
|
if (slash < 0) return this._nativeModelName(resolved);
|
|
356
361
|
const idVendor = resolved.slice(0, slash);
|
|
@@ -358,7 +363,7 @@ class DirectLLMMethod extends LLMMethod {
|
|
|
358
363
|
const plainName = /^[A-Za-z0-9._-]+$/.test(name);
|
|
359
364
|
if (idVendor === vendor && plainName) return this._nativeModelName(name);
|
|
360
365
|
|
|
361
|
-
const notes = [
|
|
366
|
+
const notes = [from].filter(Boolean);
|
|
362
367
|
const what = `"${model}"${notes.length > 0 ? ` (${notes.join(', ')})` : ''}`;
|
|
363
368
|
const servedBy = Object.entries(DIRECT_MODEL_VENDORS).find(([, v]) => v === idVendor)?.[0] || null;
|
|
364
369
|
const label = this._getProviderLabel();
|
package/lib/methods/gemini.js
CHANGED
|
@@ -18,7 +18,7 @@ import { estimateLlmCost } from './provider-pricing.js';
|
|
|
18
18
|
|
|
19
19
|
// The default model, named ONCE — it was previously written twice (here and
|
|
20
20
|
// as an inline fallback in estimateCost()) and the two could drift.
|
|
21
|
-
const DEFAULT_MODEL = 'gemini-
|
|
21
|
+
const DEFAULT_MODEL = 'gemini-3.8-flash';
|
|
22
22
|
|
|
23
23
|
class GeminiMethod extends DirectLLMMethod {
|
|
24
24
|
constructor(options = {}) {
|
|
@@ -66,6 +66,7 @@ import { LLMMethod, inferKeyTypes, isUnsafeKey, buildSystemMessage, buildUserMes
|
|
|
66
66
|
import { DEFAULT_COACHING_DIR, loadCoachingData, findDictionaryMatches, projectGlossary } from './coaching-data.js';
|
|
67
67
|
import { DEFAULT_OPENROUTER_MODEL, DEFAULT_BATCH_SIZE, DEFAULT_COACHED_TEMPERATURE, DEFAULT_MAX_RETRIES, DEFAULT_METHOD_CONCURRENCY } from '../config.js';
|
|
68
68
|
import { pMap } from '../concurrent.js';
|
|
69
|
+
import { requireExactModelId } from '../models.js';
|
|
69
70
|
import { output } from '../output.js';
|
|
70
71
|
|
|
71
72
|
/**
|
|
@@ -256,7 +257,8 @@ class LLMCoachedMethod extends TranslationMethod {
|
|
|
256
257
|
output.warn('LLM-Coached translate: no API key provided — skipping batch.');
|
|
257
258
|
return null;
|
|
258
259
|
}
|
|
259
|
-
|
|
260
|
+
// Exact slugs only (founder ruling 2026-10-05): refused before anything is sent.
|
|
261
|
+
const model = requireExactModelId(pairConfig.model || options.model || DEFAULT_OPENROUTER_MODEL);
|
|
260
262
|
const llm = new LLMMethod();
|
|
261
263
|
const batchFn = (batch, opts) => this._callCoachedBatch(batch, glossary, opts, pairConfig);
|
|
262
264
|
return this._runCoachedBatches(llm, keys, sourceFlat, langConfig, {
|
package/lib/methods/llm.js
CHANGED
|
@@ -44,6 +44,7 @@ import { isUnsafeKey } from '../security.js';
|
|
|
44
44
|
import { pMap } from '../concurrent.js';
|
|
45
45
|
import { output } from '../output.js';
|
|
46
46
|
import { getEnvOrFileVar } from '../api-key.js';
|
|
47
|
+
import { requireExactModelId } from '../models.js';
|
|
47
48
|
import { nameRules } from '../name-rules.js';
|
|
48
49
|
import { isCapturing, PREVIEW_KEY } from './request-capture.js';
|
|
49
50
|
import { projectGlossary, terminologyBlock } from './coaching-data.js';
|
|
@@ -97,7 +98,9 @@ class LLMMethod extends TranslationMethod {
|
|
|
97
98
|
// A request preview needs no key: it is shown, never sent.
|
|
98
99
|
const apiKey = options.apiKey || (isCapturing() ? PREVIEW_KEY : null);
|
|
99
100
|
const batchSize = pairConfig.batchSize || options.batchSize || DEFAULT_BATCH_SIZE;
|
|
100
|
-
|
|
101
|
+
// Exact slugs only (founder ruling 2026-10-05) — checked here too, since
|
|
102
|
+
// callers outside the pair graph (the MCP translate tool) reach this directly.
|
|
103
|
+
const model = requireExactModelId(pairConfig.model || options.model || DEFAULT_OPENROUTER_MODEL);
|
|
101
104
|
const maxRetries = pairConfig.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
102
105
|
if (!apiKey) {
|
|
103
106
|
output.warn('LLM translate: no API key provided — skipping batch.');
|
|
@@ -181,7 +184,9 @@ class LLMMethod extends TranslationMethod {
|
|
|
181
184
|
*/
|
|
182
185
|
async translateContent(prompt, pairConfig, options) {
|
|
183
186
|
const { apiKey } = options;
|
|
184
|
-
|
|
187
|
+
// Exact slugs only (founder ruling 2026-10-05) — checked here too, since
|
|
188
|
+
// callers outside the pair graph (the MCP translate tool) reach this directly.
|
|
189
|
+
const model = requireExactModelId(pairConfig.model || options.model || DEFAULT_OPENROUTER_MODEL);
|
|
185
190
|
if (!apiKey) {
|
|
186
191
|
output.warn('LLM translateContent: no API key provided — skipping.');
|
|
187
192
|
return null;
|