champollion 0.3.4 → 0.4.0

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.
Files changed (132) hide show
  1. package/README.md +41 -26
  2. package/bin/cli.js +53 -5
  3. package/index.js +63 -2
  4. package/lib/api-key.js +17 -4
  5. package/lib/autofix.js +83 -36
  6. package/lib/bridge/method_bridge.py +15 -3
  7. package/lib/cards/reader.js +34 -0
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +286 -85
  11. package/lib/commands/audit.js +10 -3
  12. package/lib/commands/card.js +583 -226
  13. package/lib/commands/doctor.js +54 -18
  14. package/lib/commands/help.js +37 -32
  15. package/lib/commands/init.js +1689 -87
  16. package/lib/commands/integrity.js +127 -40
  17. package/lib/commands/leaderboard.js +187 -67
  18. package/lib/commands/models.js +9 -2
  19. package/lib/commands/provenance.js +7 -2
  20. package/lib/commands/recommend.js +43 -14
  21. package/lib/commands/register-corpus.js +632 -125
  22. package/lib/commands/seal-corpus.js +1 -1
  23. package/lib/commands/status.js +564 -27
  24. package/lib/commands/submit.js +17 -12
  25. package/lib/commands/sync.js +31 -7
  26. package/lib/commands/tm.js +15 -9
  27. package/lib/commands/verify.js +27 -3
  28. package/lib/commands/wrap.js +63 -5
  29. package/lib/commands/xliff.js +135 -64
  30. package/lib/commercial-eligibility.js +1 -1
  31. package/lib/config.js +196 -14
  32. package/lib/content-estimate.js +96 -0
  33. package/lib/content-refusals.js +270 -0
  34. package/lib/content-review.js +372 -0
  35. package/lib/content-sync.js +1127 -344
  36. package/lib/content.js +94 -7
  37. package/lib/corpus-registration.mjs +194 -35
  38. package/lib/cost-label.js +29 -0
  39. package/lib/cost-report.js +726 -78
  40. package/lib/diff.js +38 -4
  41. package/lib/docusaurus-sync.js +965 -253
  42. package/lib/edit-distance.js +31 -0
  43. package/lib/fallback.js +964 -0
  44. package/lib/file-scope.js +106 -0
  45. package/lib/flatten.js +80 -3
  46. package/lib/flutter-locales.js +124 -0
  47. package/lib/format.js +266 -12
  48. package/lib/hash.js +146 -21
  49. package/lib/icu-structure.js +929 -0
  50. package/lib/integrity.js +223 -75
  51. package/lib/language-pair.js +157 -0
  52. package/lib/lint.js +78 -16
  53. package/lib/local-only-marks.js +106 -0
  54. package/lib/locale-layout.js +1103 -0
  55. package/lib/locale-state.js +571 -0
  56. package/lib/methods/anthropic.js +5 -0
  57. package/lib/methods/apertium.js +6 -3
  58. package/lib/methods/api.js +138 -25
  59. package/lib/methods/base.js +17 -0
  60. package/lib/methods/coaching-data.js +153 -0
  61. package/lib/methods/content-separator.js +43 -0
  62. package/lib/methods/deepl.js +1 -1
  63. package/lib/methods/direct-llm.js +252 -103
  64. package/lib/methods/external.js +146 -63
  65. package/lib/methods/gemini.js +1 -0
  66. package/lib/methods/google-translate.js +1 -0
  67. package/lib/methods/http-utils.js +41 -0
  68. package/lib/methods/libretranslate.js +7 -2
  69. package/lib/methods/llm-coached.js +68 -128
  70. package/lib/methods/llm.js +80 -31
  71. package/lib/methods/local.js +93 -10
  72. package/lib/methods/microsoft-translator.js +1 -2
  73. package/lib/methods/openai.js +4 -2
  74. package/lib/methods/openrouter-client.js +20 -19
  75. package/lib/methods/openrouter-pricing.js +150 -13
  76. package/lib/methods/prompt-methods.js +20 -0
  77. package/lib/methods/provider-pricing.js +42 -1
  78. package/lib/methods/request-capture.js +104 -0
  79. package/lib/methods/tilde.js +1 -1
  80. package/lib/methods/translated.js +1 -2
  81. package/lib/missing-key.js +93 -0
  82. package/lib/models.js +11 -0
  83. package/lib/name-rules.js +32 -0
  84. package/lib/named-keys.js +172 -0
  85. package/lib/no-translate.js +4 -3
  86. package/lib/output.js +160 -19
  87. package/lib/pairs.js +586 -30
  88. package/lib/placeholders.js +394 -0
  89. package/lib/plugins.js +8 -0
  90. package/lib/plural-gap-redo.js +109 -0
  91. package/lib/plurals.js +323 -0
  92. package/lib/po.js +1187 -0
  93. package/lib/public-catalogue.js +74 -0
  94. package/lib/recommend.js +527 -32
  95. package/lib/redo.js +95 -0
  96. package/lib/refusal-category.js +44 -0
  97. package/lib/registers.js +255 -11
  98. package/lib/repair-script.js +20 -13
  99. package/lib/scripts.js +6 -1
  100. package/lib/seal.mjs +4 -3
  101. package/lib/sealed-qualifier.mjs +1 -1
  102. package/lib/segment.js +2 -1
  103. package/lib/seo.js +19 -9
  104. package/lib/serve.js +43 -6
  105. package/lib/shared-output-seed.js +164 -0
  106. package/lib/source-contexts.js +39 -0
  107. package/lib/submit.mjs +57 -5
  108. package/lib/sync.js +2923 -474
  109. package/lib/terminology.js +13 -4
  110. package/lib/tm-evict.js +179 -0
  111. package/lib/tm-seed.js +5 -2
  112. package/lib/tm.js +818 -36
  113. package/lib/translate-pair.js +639 -34
  114. package/lib/translate.js +78 -5
  115. package/lib/types.js +22 -3
  116. package/lib/validate.js +880 -17
  117. package/lib/verify.js +1296 -104
  118. package/lib/watch.js +32 -13
  119. package/lib/xliff.js +44 -3
  120. package/package.json +1 -1
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/cards-fallback.json +1 -1
  123. package/shared/curated-orthography-conventions.json +26 -8
  124. package/shared/gettext-plural-forms.json +45 -0
  125. package/shared/method-registry.json +2 -0
  126. package/shared/metric-registry.json +96 -18
  127. package/shared/schemas/champollion-plugin.schema.json +4 -0
  128. package/shared/schemas/corpora-card.schema.json +8 -2
  129. package/shared/schemas/method-index-record.schema.json +67 -0
  130. package/shared/schemas/method-registry.schema.json +4 -0
  131. package/shared/schemas/metric-registry.schema.json +55 -1
  132. package/shared/docent/corpus.json +0 -11739
package/lib/pairs.js CHANGED
@@ -19,15 +19,23 @@
19
19
 
20
20
  import { DEFAULT_REGISTERS, getLanguageCard, resolveCode, DEFAULT_REGISTER_FALLBACK } from './registers.js';
21
21
  import { resolveTargetScript, validateScriptFallback, converterKeyForLocale } from './scripts.js';
22
- import { getMethod } from './translate.js';
22
+ import { getMethod, METHOD_REGISTRY } from './translate.js';
23
+ import { COACHED_PROVIDERS, normalizeProvider } from './methods/llm-coached.js';
23
24
  import { DEFAULT_OPENROUTER_MODEL, DEFAULT_BATCH_SIZE, DEFAULT_MAX_RETRIES } from './config.js';
25
+ import { resolveModel } from './models.js';
26
+ import { output } from './output.js';
27
+ import { tmMethodKey } from './tm.js';
28
+ import { PLAIN_LLM_METHODS } from './methods/prompt-methods.js';
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
24
31
 
25
32
  /**
26
- * Quality tiers — define the expected reliability of a translation.
27
- *
28
- * These tiers aren't arbitrary labels. Each corresponds to a concrete
29
- * verification level that determines how much the output can be trusted
30
- * without human review.
33
+ * Quality tiers — a label a pair's config (`pairs.<pair>.qualityTier`) or a
34
+ * served method's manifest declares about its output. Each description says
35
+ * what the label claims; nothing measures or enforces it — sync translates
36
+ * the same whatever the tier, `serve` advertises it, and `status` shows it
37
+ * only when someone set it (the default is no claim at all; Round 14,
38
+ * Next.js persona: "quality: Standard" on every pair explained nothing).
31
39
  */
32
40
  const QUALITY_TIERS = {
33
41
  standard: {
@@ -68,27 +76,423 @@ const PAIR_DEFAULTS = {
68
76
  * class's _getDefaultModel() fires with the correct provider-specific slug.
69
77
  */
70
78
  const DIRECT_PROVIDER_METHODS = new Set([
71
- 'gemini', 'openai', 'anthropic', 'deepl',
79
+ 'gemini', 'openai', 'anthropic', 'local', 'deepl',
72
80
  'google-translate', 'microsoft-translator', 'libretranslate',
73
81
  ]);
74
82
 
83
+ /**
84
+ * Methods whose transport is chosen by `provider`. Every other method names
85
+ * its own engine, so a `provider` set on one of them is a config error.
86
+ */
87
+ const PROVIDER_ROUTED_METHODS = new Set(['llm', 'llm-coached']);
88
+
89
+ /**
90
+ * Resolve a pair's transport from its method and the `provider` settings.
91
+ *
92
+ * - `ownProvider` is set at the pair or language level; `globalProvider` at
93
+ * the top level. The more specific one wins.
94
+ * - Only llm / llm-coached are provider-routed. A top-level provider is a
95
+ * default for those and leaves other methods alone; a pair- or
96
+ * language-level provider on any other method is refused (unless it just
97
+ * restates the method, e.g. method "openai" + provider "openai").
98
+ * - Plain `llm` on a direct provider IS that provider's method (the direct
99
+ * methods build the same prompt), so it is rewritten to it. llm-coached
100
+ * keeps its method and dispatches on `provider` itself.
101
+ * - An unknown provider throws — it must never quietly ride OpenRouter.
102
+ *
103
+ * @param {string} method - Method after method resolution
104
+ * @param {string|null|undefined} ownProvider - Pair/language-level provider
105
+ * @param {string|null|undefined} globalProvider - Top-level provider
106
+ * @returns {{ method: string, provider: string|null }}
107
+ */
108
+ function resolveTransportForPair(method, ownProvider, globalProvider) {
109
+ const check = (raw) => {
110
+ if (raw == null) return null;
111
+ const p = normalizeProvider(raw);
112
+ if (!COACHED_PROVIDERS.includes(p)) {
113
+ throw new Error(
114
+ `Unknown provider "${raw}". Supported providers: ${COACHED_PROVIDERS.join(', ')}.`
115
+ );
116
+ }
117
+ return p;
118
+ };
119
+ const own = check(ownProvider);
120
+ const global = check(globalProvider);
121
+
122
+ if (!PROVIDER_ROUTED_METHODS.has(method)) {
123
+ if (own && own !== method) {
124
+ throw new Error(
125
+ `provider "${own}" cannot apply to method "${method}" — only llm and ` +
126
+ `llm-coached are routed by provider. Remove "provider", or use ` +
127
+ `method "${own === 'openrouter' ? 'llm' : own}".`
128
+ );
129
+ }
130
+ return { method, provider: null };
131
+ }
132
+
133
+ const provider = own ?? global ?? 'openrouter';
134
+ if (method === 'llm' && provider !== 'openrouter') {
135
+ return { method: provider, provider };
136
+ }
137
+ return { method, provider };
138
+ }
139
+
75
140
  /**
76
141
  * Resolve the model for a translation pair.
77
142
  *
78
- * - If the user explicitly set a model, use it (they know what they want).
79
- * - If the method is a direct provider, return null — let the method class
80
- * pick its own default via _getDefaultModel().
81
- * - Otherwise (OpenRouter-style methods), use the global default.
143
+ * - If the user explicitly set a model for the pair/language, use it.
144
+ * - If the pair talks to a direct provider (a direct method, or llm-coached
145
+ * on a non-OpenRouter provider), the global default is an OpenRouter slug
146
+ * and would be wrong there. Use `providerModel` — the top-level `model`
147
+ * when it was written alongside a top-level `provider` for this very
148
+ * transport — else null so the method's _getDefaultModel() fires.
149
+ * - Otherwise (OpenRouter), use the global default.
82
150
  *
83
151
  * @param {string|null} explicitModel - Model from per-language or per-pair config
84
- * @param {string} method - Translation method name
152
+ * @param {string} method - Translation method name (after transport resolution)
85
153
  * @param {string} globalDefault - Global model from config (OpenRouter slug)
86
- * @returns {string|null} Resolved model, or null for direct providers
154
+ * @param {string|null} [provider] - Resolved transport (null for non-routed methods)
155
+ * @param {string|null} [providerModel] - Top-level model that belongs to `provider`
156
+ * @returns {string|null} Resolved model, or null to use the method default
157
+ */
158
+ function resolveModelForPair(explicitModel, method, globalDefault, provider = null, providerModel = null, from = null) {
159
+ if (explicitModel) return modelIdForTransport(explicitModel, method, provider, from);
160
+ if (DIRECT_PROVIDER_METHODS.has(method) || (provider && provider !== 'openrouter')) {
161
+ return modelIdForTransport(providerModel, method, provider, 'from the top-level "model"');
162
+ }
163
+ return modelIdForTransport(globalDefault, method, provider);
164
+ }
165
+
166
+ /** Transports that name models themselves (lib/methods/direct-llm.js resolveModelId). */
167
+ const MODEL_ID_TRANSPORTS = new Set(['openai', 'anthropic', 'gemini', 'local']);
168
+
169
+ /**
170
+ * The model id a pair's transport is sent, as written in the config or on
171
+ * --model:
172
+ * - OpenRouter (llm, llm-coached by default): an alias from
173
+ * shared/model-aliases.json becomes its full id ("gemini-flash" →
174
+ * "google/gemini-3.5-flash"). --model's alias used to reach OpenRouter
175
+ * unresolved, as "gemini-flash".
176
+ * - A direct provider (openai, anthropic, gemini — as a method or as the
177
+ * provider of llm-coached): its own name ("openai/gpt-5.5" → "gpt-5.5");
178
+ * an id it has no name for ("google/…" on openai) THROWS, naming a model
179
+ * that method can run. It used to be sent as is, and every request failed.
180
+ * - local, and a gateway set with OPENAI_API_BASE: as written.
181
+ * - Engines (deepl, api, …): as written (they run no model of ours).
182
+ *
183
+ * @param {string|null} model
184
+ * @param {string} method - Method after transport resolution
185
+ * @param {string|null} provider - Resolved transport (null for non-routed methods)
186
+ * @param {string|null} [from] - Where the id was set, for a refusal
187
+ * @returns {string|null}
188
+ * @throws {Error} code CHAMPOLLION_MODEL_ROUTE (the caller prefixes the pair)
189
+ */
190
+ function modelIdForTransport(model, method, provider = null, from = null) {
191
+ if (!model) return model;
192
+ const transport = PROVIDER_ROUTED_METHODS.has(method) ? (provider || 'openrouter') : method;
193
+ if (transport === 'openrouter') return resolveModel(model);
194
+ if (MODEL_ID_TRANSPORTS.has(transport)) return new METHOD_REGISTRY[transport]().resolveModelId(model, { from });
195
+ return model;
196
+ }
197
+
198
+ /**
199
+ * resolveModelForPair, with a refused model id naming the pair — the same
200
+ * shape as a refused provider. `from` says where an explicit id was set.
201
+ */
202
+ function resolveModelNamed(where, from, explicitModel, method, globalDefault, provider, providerModel) {
203
+ try {
204
+ return resolveModelForPair(explicitModel, method, globalDefault, provider, providerModel, from);
205
+ } catch (err) {
206
+ if (err.code === 'CHAMPOLLION_MODEL_ROUTE') err.message = `${where}: ${err.message}`;
207
+ throw err;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Where a pair's explicit model id was written, for a refusal message:
213
+ * --model, else the first config field that set it (null = none set).
214
+ *
215
+ * @param {object} config
216
+ * @param {Array<[string, unknown]>} fields - [label, value] in precedence order
217
+ * @returns {string|null}
218
+ */
219
+ function modelSource(config, fields) {
220
+ if (config._modelOverride) return 'from --model';
221
+ const set = fields.find(([, value]) => value);
222
+ return set ? `from ${set[0]}` : null;
223
+ }
224
+
225
+ /**
226
+ * Fields a pair's `fallback` may set. Everything else on a resolved fallback
227
+ * is inherited from its pair — the target language's name, register, script,
228
+ * coaching, prompt context — because those describe the LANGUAGE, and a
229
+ * fallback translates the same language for the same project.
230
+ */
231
+ const FALLBACK_FIELDS = new Set([
232
+ 'method', 'model', 'provider', 'endpoint', 'apiKey', 'methodPlugin', 'acceptsInstructions',
233
+ 'register', 'temperature', 'coachingFile', 'coachingPrompt', 'promptContext',
234
+ 'batchSize', 'maxRetries', 'qualityTier', 'contentSegmentation', 'name',
235
+ ]);
236
+
237
+ /** Fields refused on a fallback, with the reason the error gives. */
238
+ const FALLBACK_REFUSED = {
239
+ fallback: 'a fallback cannot have its own fallback — one per pair',
240
+ script: 'the writing system belongs to the pair, not to one method — set "script" on the pair',
241
+ scriptFallback: 'transliteration rules belong to the pair — set "scriptFallback" on the pair',
242
+ source: 'a fallback translates its own pair — it has no source of its own',
243
+ target: 'a fallback translates its own pair — it has no target of its own',
244
+ };
245
+
246
+ /** Fields whose value a fallback inherits from its pair unless it sets them. */
247
+ const FALLBACK_INHERITED = [
248
+ 'register', 'temperature', 'coachingFile', 'coachingPrompt', 'promptContext',
249
+ 'batchSize', 'maxRetries', 'qualityTier', 'contentSegmentation', 'name',
250
+ ];
251
+
252
+ /**
253
+ * Say a fallback note once per process: resolvePairs runs several times in
254
+ * one sync (the run, then verify), and the same warning twice is noise.
255
+ */
256
+ const notedOnce = new Set();
257
+ function noteOnce(level, message) {
258
+ if (notedOnce.has(message)) return;
259
+ notedOnce.add(message);
260
+ output[level](message);
261
+ }
262
+
263
+ /**
264
+ * Throw, naming the pair, when `methodName` is not a method the CLI can run.
265
+ * getMethod() supplies the precise message (harness-only engine vs typo).
266
+ */
267
+ function assertKnownMethod(methodName, where) {
268
+ if (METHOD_REGISTRY[methodName]) return;
269
+ try {
270
+ getMethod(methodName);
271
+ } catch (err) {
272
+ throw new Error(`${where}: ${err.message}`);
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Read a coaching file a pair, a language or a fallback names for itself.
278
+ *
279
+ * WHY: only the TOP-LEVEL coachingFile was read (lib/config.js, into
280
+ * coachingPrompt). A language's, a pair's or a fallback's own coachingFile
281
+ * reached the plain LLM methods' prompt never — their prompt carries
282
+ * coachingPrompt only — and llm-coached only when no less specific level had
283
+ * coaching text (that text won). A `local` fallback given a coaching file
284
+ * ran uncoached, and its cache key did not change either (Round 10, school
285
+ * persona). The text read here is what the prompt carries and what the
286
+ * cache key fingerprints (lib/tm.js tmMethodKey), so an edit re-translates.
287
+ *
288
+ * Fails loud, naming where the file was named: a run never goes ahead
289
+ * uncoached when coaching was asked for (the llm-coached contract).
290
+ *
291
+ * @param {string} file - As written in the config
292
+ * @param {string} projectDir - What a relative path is relative to
293
+ * @param {string} where - e.g. 'en:crk: "fallback"'
294
+ * @returns {string}
295
+ */
296
+ function readOwnCoachingFile(file, projectDir, where) {
297
+ const resolved = path.isAbsolute(file) ? file : path.resolve(projectDir, file);
298
+ let text;
299
+ try {
300
+ text = fs.readFileSync(resolved, 'utf-8');
301
+ } catch (err) {
302
+ throw new Error(
303
+ `${where}: "coachingFile" ${JSON.stringify(file)} cannot be read (${err.code || err.message}; resolved to ${resolved}). `
304
+ + 'Fix the path, or remove "coachingFile" — Champollion does not translate without the coaching a config asks for.'
305
+ );
306
+ }
307
+ text = text.trim();
308
+ if (!text) {
309
+ throw new Error(
310
+ `${where}: "coachingFile" ${JSON.stringify(file)} (${resolved}) is empty. `
311
+ + 'Write the coaching in it, or remove "coachingFile".'
312
+ );
313
+ }
314
+ return text;
315
+ }
316
+
317
+ /**
318
+ * The coaching text the version before 2026-10 SENT for a pair (lib/tm.js
319
+ * legacyMethodKey reuses entries made with it only when it is the text sent
320
+ * now): the plain LLM methods sent `coachingPrompt` as it resolved then — a
321
+ * level's own coachingFile never; llm-coached that text, else its
322
+ * coachingFile's content.
323
+ *
324
+ * @param {object} pc - The pair (or fallback) with method, coachingFile, coachingPrompt
325
+ * @param {string|null} legacyPrompt - coachingPrompt as the old precedence resolved it
326
+ * @param {string|null} ownFile - The file this level names for itself (already in coachingPrompt)
327
+ * @param {string} projectDir
328
+ * @returns {string|null}
329
+ */
330
+ function legacyCoachingSent(pc, legacyPrompt, ownFile, projectDir) {
331
+ if (PLAIN_LLM_METHODS.has(pc.method)) return legacyPrompt;
332
+ if (pc.method !== 'llm-coached') return pc.coachingPrompt ?? null;
333
+ if (typeof legacyPrompt === 'string' && legacyPrompt.trim()) return legacyPrompt;
334
+ if (typeof pc.coachingFile !== 'string' || !pc.coachingFile.trim()) return null;
335
+ if (ownFile && ownFile === pc.coachingFile) return pc.coachingPrompt ?? null;
336
+ try {
337
+ const resolved = path.isAbsolute(pc.coachingFile) ? pc.coachingFile : path.resolve(projectDir, pc.coachingFile);
338
+ return fs.readFileSync(resolved, 'utf-8').trim() || null;
339
+ } catch {
340
+ return null; // it failed then too (llm-coached refuses to run uncoached)
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Resolve a pair's `fallback` into a full pair config.
346
+ *
347
+ * A fallback is the method that translates what the pair's own method could
348
+ * not translate safely: keys the quality gate refused or the method returned
349
+ * nothing for, Markdown blocks it dropped or damaged. It goes through the
350
+ * same machinery as a pair — resolveTransportForPair for `method`/`provider`,
351
+ * resolveModelForPair for `model` — so it is a complete pair config the sync
352
+ * pipeline can run unchanged: its own TM key, its own cost estimate, the
353
+ * same quality gate.
354
+ *
355
+ * `--method` / `--model` override the pair's OWN method only. The fallback
356
+ * resolves against what the config FILE set (config._fileModel,
357
+ * config._fileDefaultMethod, recorded by lib/config.js before the flags
358
+ * apply), so trying a different primary for one run never changes what
359
+ * catches its failures.
360
+ *
361
+ * @param {string} pairKey - e.g. "en:crk" (named in every error)
362
+ * @param {object} raw - The `fallback` object as written in the config
363
+ * @param {object} pair - The resolved pair it belongs to
364
+ * @param {object} config - Resolved config
365
+ * @param {string} [projectDir] - What a relative coachingFile is relative to
366
+ * @returns {object|null} Resolved fallback pair config, or null when a CLI
367
+ * override made the pair's own method identical to its fallback
368
+ * @throws {Error} On a malformed fallback — always naming the pair
369
+ */
370
+ function resolveFallbackForPair(pairKey, raw, pair, config, projectDir = process.cwd()) {
371
+ const where = `${pairKey}: "fallback"`;
372
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
373
+ throw new Error(
374
+ `${where} must be an object naming a method, e.g. "fallback": { "method": "llm-coached" } `
375
+ + `(got ${JSON.stringify(raw)}).`
376
+ );
377
+ }
378
+ for (const [field, why] of Object.entries(FALLBACK_REFUSED)) {
379
+ if (Object.prototype.hasOwnProperty.call(raw, field)) {
380
+ throw new Error(`${where} cannot set "${field}": ${why}.`);
381
+ }
382
+ }
383
+ const unknown = Object.keys(raw).filter(k => !k.startsWith('_') && !FALLBACK_FIELDS.has(k));
384
+ if (unknown.length > 0) {
385
+ noteOnce('warn',
386
+ `${where}: field(s) ${unknown.map(k => `"${k}"`).join(', ')} have no effect on a fallback. `
387
+ + `A fallback accepts: ${[...FALLBACK_FIELDS].join(', ')}.`
388
+ );
389
+ }
390
+ if (typeof raw.method !== 'string' || raw.method.trim() === '') {
391
+ throw new Error(
392
+ `${where} needs a "method" — the method that translates what the pair's own method `
393
+ + `(${pair.method}) cannot, e.g. "fallback": { "method": "llm-coached" }.`
394
+ );
395
+ }
396
+ assertKnownMethod(raw.method, where);
397
+
398
+ // The FILE's model and default method, not the --model/--method override.
399
+ const fileModel = (config._modelOverride ? config._fileModel : config.model) || DEFAULT_OPENROUTER_MODEL;
400
+ const fileModelExplicit = config._modelOverride ? !!config._fileModelExplicit : !!config._modelExplicit;
401
+ const fileDefaultMethod = config._methodOverride ? config._fileDefaultMethod : config.defaultMethod;
402
+ const globalProvider = config.provider == null ? null : normalizeProvider(config.provider);
403
+
404
+ let method;
405
+ let provider;
406
+ try {
407
+ ({ method, provider } = resolveTransportForPair(raw.method, raw.provider, config.provider));
408
+ } catch (err) {
409
+ throw new Error(`${where}: ${err.message}`);
410
+ }
411
+ const providerModel = fileModelExplicit && (
412
+ (provider && provider !== 'openrouter' && provider === globalProvider)
413
+ || (method === fileDefaultMethod && DIRECT_PROVIDER_METHODS.has(method))
414
+ ) ? fileModel : null;
415
+ const model = resolveModelNamed(where, raw.model ? 'from the fallback\'s "model"' : 'from the top-level "model"', raw.model, method, fileModel, provider, providerModel);
416
+
417
+ // Start from the pair (the language's facts and settings), drop what
418
+ // belongs to the pair's own method, then apply the fallback's fields.
419
+ const fallback = { ...pair };
420
+ delete fallback.fallback;
421
+ const _defaults = new Set();
422
+ for (const field of FALLBACK_INHERITED) {
423
+ if (raw[field] != null) fallback[field] = raw[field];
424
+ else _defaults.add(field);
425
+ }
426
+ if (raw.model == null) _defaults.add('model');
427
+ if (raw.register != null) {
428
+ // Same preset-key rule as a pair-level register (see resolvePairs).
429
+ const card = getLanguageCard(pair.target);
430
+ fallback.registerPreset = card?.registers?.[raw.register] != null ? raw.register : null;
431
+ }
432
+ Object.assign(fallback, {
433
+ method,
434
+ provider,
435
+ model,
436
+ endpoint: raw.endpoint || null,
437
+ // Its own token, never the pair's: a fallback endpoint is another server.
438
+ apiKey: raw.apiKey || null,
439
+ methodPlugin: raw.methodPlugin || null,
440
+ // Its own declaration too (another endpoint): unknown unless stated.
441
+ acceptsInstructions: typeof raw.acceptsInstructions === 'boolean' ? raw.acceptsInstructions : null,
442
+ isFallback: true,
443
+ _defaults,
444
+ });
445
+
446
+ // Its own coaching file: read, so its prompt and its cache key carry the
447
+ // text (readOwnCoachingFile). Its own coachingPrompt wins, as on a pair.
448
+ fallback._legacyCoachingPrompt = raw.coachingPrompt ?? pair._legacyCoachingPrompt ?? null;
449
+ if (raw.coachingFile != null && raw.coachingPrompt == null) {
450
+ fallback.coachingPrompt = readOwnCoachingFile(raw.coachingFile, projectDir, where);
451
+ }
452
+ fallback._legacyCoachingSent = legacyCoachingSent(
453
+ fallback, fallback._legacyCoachingPrompt, raw.coachingPrompt == null ? (raw.coachingFile ?? null) : null, projectDir,
454
+ );
455
+
456
+ // A fallback identical to its pair would only repeat the same translation.
457
+ // Identity is what the TM keys on (method, model — or an api pair's
458
+ // endpoint — register, coaching) plus the transport and temperature.
459
+ const sameAs = tmMethodKey(fallback) === tmMethodKey(pair)
460
+ && ['provider', 'temperature'].every(f => (fallback[f] ?? null) === (pair[f] ?? null));
461
+ if (sameAs) {
462
+ if (config._methodOverride || config._modelOverride) {
463
+ noteOnce('info',
464
+ `${pairKey}: --method/--model made the pair's own method the same as its fallback `
465
+ + `(${method}${model ? `, ${model}` : ''}) — no fallback this run.`
466
+ );
467
+ return null;
468
+ }
469
+ throw new Error(
470
+ `${where} is the same method as the pair itself (${method}${model ? `, model ${model}` : ''}) — `
471
+ + 'it would only repeat the same translation. Name a different method or model.'
472
+ );
473
+ }
474
+ return fallback;
475
+ }
476
+
477
+ /**
478
+ * The gender guidance a pair's prompt carries, and where it comes from. The
479
+ * default is the catalogue's for the language (shared/catalogue/gender-
480
+ * guidance.json — French: écriture inclusive with the interpunct,
481
+ * "Connecté·e"); a config can replace it with its own instruction or turn it
482
+ * off with `false` — per pair, per language, or for every language. Never
483
+ * changed silently: absent means the catalogue's, as before (Round 8, Django
484
+ * persona: the French prompt asked for écriture inclusive and nothing showed it).
485
+ *
486
+ * @param {Array<*>} settings - The config values in precedence order (pair, language, global)
487
+ * @param {string|null} catalogue - The card's guidance (getLanguageCard().gender.inclusiveGuidance)
488
+ * @returns {{ genderGuidance: string|null, genderGuidanceSource: 'config'|'off'|'catalogue'|null }}
87
489
  */
88
- function resolveModelForPair(explicitModel, method, globalDefault) {
89
- if (explicitModel) return explicitModel;
90
- if (DIRECT_PROVIDER_METHODS.has(method)) return null;
91
- return globalDefault;
490
+ function resolveGenderGuidance(settings, catalogue) {
491
+ for (const v of settings) {
492
+ if (v === false) return { genderGuidance: null, genderGuidanceSource: 'off' };
493
+ if (typeof v === 'string' && v.trim()) return { genderGuidance: v.trim(), genderGuidanceSource: 'config' };
494
+ }
495
+ return catalogue ? { genderGuidance: catalogue, genderGuidanceSource: 'catalogue' } : { genderGuidance: null, genderGuidanceSource: null };
92
496
  }
93
497
 
94
498
  /**
@@ -106,21 +510,52 @@ function resolveModelForPair(explicitModel, method, globalDefault) {
106
510
  * - dir: text directionality ('ltr' or 'rtl')
107
511
  * - scripts: available script conversions (if any)
108
512
  * - endpoint: API endpoint URL for the bare "api" method (if set)
513
+ * - fallback: (only when configured) a full pair config for the second
514
+ * method that translates what this one cannot translate
515
+ * safely — see resolveFallbackForPair
109
516
  *
110
517
  * Pair keys use colon separator: "en:fr", "en:crk".
111
518
  * Legacy arrow formats (en→fr, en->fr) in config.pairs are accepted
112
519
  * by parsePairKey but stored internally in colon format.
113
520
  *
114
521
  * @param {import('./types.js').ChampollionConfig} config - Resolved config (post-migration, post-defaults)
522
+ * @param {{ cwd?: string }} [options] - cwd: the project directory a pair's,
523
+ * language's or fallback's own coachingFile is relative to (the directory
524
+ * resolveConfig read the config in; the CLI's working directory by default)
115
525
  * @returns {Map<string, import('./types.js').PairConfig>} Pair graph
116
526
  */
117
- function resolvePairs(config) {
527
+ function resolvePairs(config, { cwd = process.cwd() } = {}) {
118
528
  const pairs = new Map();
119
529
  const inputLocale = config.inputLocale;
120
530
  const defaultModel = config.model || DEFAULT_OPENROUTER_MODEL;
121
531
  const defaultBatchSize = config.batchSize || DEFAULT_BATCH_SIZE;
122
532
  const defaultMethod = config.defaultMethod || PAIR_DEFAULTS.method;
123
533
 
534
+ // A top-level `model` written next to a top-level direct `provider` names a
535
+ // model ON that provider (the harness export-config shape). Without an
536
+ // explicit top-level model, config.model is the OpenRouter default slug and
537
+ // must never be sent to a direct provider.
538
+ const globalProvider = config.provider == null ? null : normalizeProvider(config.provider);
539
+ // An explicit global model belongs to the global transport: the global
540
+ // provider, or the default METHOD when that is a direct one. Without the
541
+ // second case, `defaultMethod: "local"` + `model: "stub-1"` ran every pair
542
+ // on the local method's fallback model (llama3.1) — and the cache key
543
+ // carried no model, so a model switch went unnoticed (synthetic review).
544
+ const providerModelFor = (provider, method = null) => (
545
+ config._modelExplicit && (
546
+ (provider && provider !== 'openrouter' && provider === globalProvider)
547
+ || (method && method === config.defaultMethod && DIRECT_PROVIDER_METHODS.has(method))
548
+ )
549
+ ? defaultModel
550
+ : null
551
+ );
552
+
553
+ // The UNRESOLVED method/provider/model each pair was configured with. Step 1
554
+ // may rewrite `llm` to a direct provider's method and fill the model from a
555
+ // default; a Step-2 override that changes the transport must re-derive from
556
+ // what the user wrote, not from those resolved values.
557
+ const rawSettings = new Map();
558
+
124
559
  // Step 1: Build pairs from the `languages` array (simple mode)
125
560
  const languages = config.resolvedLanguages || {};
126
561
  for (const [code, langConfig] of Object.entries(languages)) {
@@ -138,13 +573,44 @@ function resolvePairs(config) {
138
573
  // than explicitly set by the user. resolvePluginForPair uses this to
139
574
  // let plugin config override defaults while respecting explicit settings.
140
575
  const _defaults = new Set();
141
- const method = langConfig.method || defaultMethod;
142
- const model = resolveModelForPair(langConfig.model, method, defaultModel);
143
- if (!langConfig.model) _defaults.add('model');
576
+ // A language's own model/provider belong to its own method: when
577
+ // --method overrides that method they go with it (--model still applies).
578
+ const langOverridden = Boolean(config._methodOverride && langConfig.method
579
+ && langConfig.method !== config._methodOverride);
580
+ let method;
581
+ let provider;
582
+ try {
583
+ ({ method, provider } = resolveTransportForPair(
584
+ config._methodOverride || langConfig.method || defaultMethod,
585
+ langOverridden ? null : langConfig.provider, config.provider,
586
+ ));
587
+ } catch (err) {
588
+ err.message = `${pairKey}: ${err.message}`;
589
+ throw err;
590
+ }
591
+ const langModel = config._modelOverride || (langOverridden ? undefined : langConfig.model);
592
+ const model = resolveModelNamed(pairKey, modelSource(config, [[`languages.${code}.model`, langModel]]),
593
+ langModel, method, defaultModel, provider, providerModelFor(provider, method));
594
+ rawSettings.set(pairKey, {
595
+ method: config._methodOverride || langConfig.method || defaultMethod,
596
+ provider: langOverridden ? null : (langConfig.provider ?? null),
597
+ model: langModel || null,
598
+ // The language's `fallback`, resolved in Step 4 (after the script
599
+ // decision, which a fallback shares with its pair).
600
+ fallback: langConfig.fallback ?? null,
601
+ // The coaching this language names for itself (read in Step 3b).
602
+ coaching: langConfig.coachingFile != null || langConfig.coachingPrompt != null
603
+ ? { file: langConfig.coachingFile ?? null, prompt: langConfig.coachingPrompt ?? null, where: `languages.${code}` }
604
+ : null,
605
+ });
606
+ if (!langModel) _defaults.add('model');
144
607
  const batchSize = langConfig.batchSize || defaultBatchSize;
145
608
  if (!langConfig.batchSize) _defaults.add('batchSize');
146
609
  const register = langConfig.register || registerInfo.register || DEFAULT_REGISTER_FALLBACK;
147
610
  if (!langConfig.register) _defaults.add('register');
611
+ // A language entry sets no quality tier: the pair's is the default
612
+ // label, not one anybody chose (status shows only a chosen one).
613
+ _defaults.add('qualityTier');
148
614
 
149
615
  // Track fields from system defaults so plugins can override them.
150
616
  // If the user didn't explicitly set these, they're defaults.
@@ -163,6 +629,9 @@ function resolvePairs(config) {
163
629
  source: inputLocale,
164
630
  target: code,
165
631
  method,
632
+ // Transport for llm-coached (dispatches on it) and the record of which
633
+ // provider a rewritten `llm` pair runs on. null for engine methods.
634
+ provider,
166
635
  model,
167
636
  qualityTier: PAIR_DEFAULTS.qualityTier,
168
637
  batchSize,
@@ -182,12 +651,21 @@ function resolvePairs(config) {
182
651
  // APIMethod falls back to pairConfig.endpoint — dropping it during
183
652
  // normalization made { method: "api", endpoint: … } configs unusable.
184
653
  endpoint: langConfig.endpoint || null,
654
+ // The api endpoint's own token (lib/methods/api.js resolveApiMethodKey:
655
+ // "${VAR}" or a literal). Documented, but dropped here until 2026-10-03.
656
+ apiKey: langConfig.apiKey || null,
657
+ // Does the api endpoint follow per-key instructions? true / false as
658
+ // declared (pair or plugin manifest); null = unknown (lib/methods/api.js).
659
+ acceptsInstructions: typeof langConfig.acceptsInstructions === 'boolean' ? langConfig.acceptsInstructions : null,
185
660
  // Structured formality info for method-specific behavior (e.g., DeepL)
186
661
  formalitySystem: card?.formality?.system || null,
187
- // Language-specific gender guidance for LLM prompts (e.g., écriture inclusive for French)
188
- genderGuidance: card?.gender?.inclusiveGuidance || null,
662
+ // Language-specific gender guidance for LLM prompts (e.g., écriture
663
+ // inclusive for French) — the catalogue's, unless the config replaces
664
+ // or turns it off ("genderGuidance").
665
+ ...resolveGenderGuidance([langConfig.genderGuidance, config.genderGuidance], card?.gender?.inclusiveGuidance || null),
189
666
  // Global prompt context from config (e.g., "This is a developer tool README")
190
667
  promptContext,
668
+ protectedTerms: config.protectedTerms || [],
191
669
  // Temperature: per-language → global config → null (method picks its own default)
192
670
  temperature,
193
671
  // Coaching: coaching file path and resolved prompt text
@@ -233,19 +711,58 @@ function resolvePairs(config) {
233
711
  const registerInfo = DEFAULT_REGISTERS[target] || {};
234
712
  const existing = pairs.get(pairKey) || {};
235
713
  const existingDefaults = existing._defaults || new Set();
714
+ const raw = rawSettings.get(pairKey) || {};
236
715
 
237
716
  // _defaults: a field is "defaulted" if NEITHER the pairOverride NOR
238
717
  // the existing pair set it explicitly. If pairOverride sets a field,
239
718
  // it clears the default flag; if it falls through to existing, it
240
719
  // inherits that pair's default tracking.
241
720
  const _defaults = new Set();
242
- const resolvedMethod = pairOverride.method || existing.method || defaultMethod;
243
- const model = resolveModelForPair(
244
- pairOverride.model || existing.model,
721
+ // Transport + model re-derive from the RAW settings (see rawSettings):
722
+ // re-resolving from existing.method/existing.model would treat a
723
+ // rewritten method or a defaulted model as if the user had written it.
724
+ // The pair's own model/provider belong to its own method: when
725
+ // --method overrides that method they go with it (--model still applies).
726
+ const pairOverridden = Boolean(config._methodOverride && pairOverride.method
727
+ && pairOverride.method !== config._methodOverride);
728
+ let resolvedMethod;
729
+ let provider;
730
+ try {
731
+ ({ method: resolvedMethod, provider } = resolveTransportForPair(
732
+ config._methodOverride || pairOverride.method || raw.method || defaultMethod,
733
+ pairOverridden ? null : (pairOverride.provider ?? raw.provider),
734
+ config.provider,
735
+ ));
736
+ } catch (err) {
737
+ err.message = `${pairKey}: ${err.message}`;
738
+ throw err;
739
+ }
740
+ const pairModel = config._modelOverride
741
+ || (pairOverridden ? undefined : (pairOverride.model || raw.model));
742
+ rawSettings.set(pairKey, {
743
+ method: config._methodOverride || pairOverride.method || raw.method || defaultMethod,
744
+ provider: pairOverridden ? null : (pairOverride.provider ?? raw.provider ?? null),
745
+ model: pairModel || null,
746
+ // A pair-level `fallback` replaces the language's; `null` removes it.
747
+ fallback: Object.prototype.hasOwnProperty.call(pairOverride, 'fallback')
748
+ ? pairOverride.fallback
749
+ : (raw.fallback ?? null),
750
+ // The most specific level that names coaching (read in Step 3b).
751
+ coaching: pairOverride.coachingFile != null || pairOverride.coachingPrompt != null
752
+ ? { file: pairOverride.coachingFile ?? null, prompt: pairOverride.coachingPrompt ?? null, where: `pairs["${rawPairKey}"]` }
753
+ : (raw.coaching ?? null),
754
+ });
755
+ const model = resolveModelNamed(
756
+ pairKey,
757
+ modelSource(config, [[`pairs["${rawPairKey}"].model`, pairModel && pairOverride.model], [`languages.${rawTarget}.model`, pairModel]]),
758
+ pairModel,
245
759
  resolvedMethod,
246
- defaultModel
760
+ defaultModel,
761
+ provider,
762
+ providerModelFor(provider, resolvedMethod),
247
763
  );
248
764
  if (!pairOverride.model && existingDefaults.has('model')) _defaults.add('model');
765
+ if (!pairOverride.qualityTier && (existingDefaults.has('qualityTier') || !existing.qualityTier)) _defaults.add('qualityTier');
249
766
  if (!pairOverride.model && !existing.model) _defaults.add('model');
250
767
  const batchSize = pairOverride.batchSize || existing.batchSize || defaultBatchSize;
251
768
  if (!pairOverride.batchSize && existingDefaults.has('batchSize')) _defaults.add('batchSize');
@@ -285,6 +802,7 @@ function resolvePairs(config) {
285
802
  source,
286
803
  target,
287
804
  method: resolvedMethod,
805
+ provider,
288
806
  model,
289
807
  qualityTier: pairOverride.qualityTier || existing.qualityTier || PAIR_DEFAULTS.qualityTier,
290
808
  batchSize,
@@ -300,12 +818,21 @@ function resolvePairs(config) {
300
818
  // Pair-level API endpoint for the bare "api" method (no plugin manifest).
301
819
  // Preserved so APIMethod's documented pairConfig.endpoint fallback works.
302
820
  endpoint: pairOverride.endpoint || existing.endpoint || null,
821
+ apiKey: pairOverride.apiKey || existing.apiKey || null,
822
+ acceptsInstructions: typeof pairOverride.acceptsInstructions === 'boolean'
823
+ ? pairOverride.acceptsInstructions
824
+ : (typeof existing.acceptsInstructions === 'boolean' ? existing.acceptsInstructions : null),
303
825
  // Plugin reference — the plugin loader will merge its config into this pair
304
826
  methodPlugin: pairOverride.methodPlugin || null,
305
827
  formalitySystem: card?.formality?.system || existing.formalitySystem || null,
306
- genderGuidance: card?.gender?.inclusiveGuidance || existing.genderGuidance || null,
828
+ ...(pairOverride.genderGuidance !== undefined && pairOverride.genderGuidance !== null
829
+ ? resolveGenderGuidance([pairOverride.genderGuidance], card?.gender?.inclusiveGuidance || null)
830
+ : existing.genderGuidanceSource
831
+ ? { genderGuidance: existing.genderGuidance || null, genderGuidanceSource: existing.genderGuidanceSource }
832
+ : resolveGenderGuidance([config.genderGuidance], card?.gender?.inclusiveGuidance || existing.genderGuidance || null)),
307
833
  // Global prompt context flows from config into every pair
308
834
  promptContext,
835
+ protectedTerms: config.protectedTerms || [],
309
836
  temperature,
310
837
  // Coaching: coaching file path and resolved prompt text
311
838
  coachingFile,
@@ -343,6 +870,32 @@ function resolvePairs(config) {
343
870
  }
344
871
  }
345
872
 
873
+ // Step 3b: Coaching text — the most specific level that names coaching
874
+ // (pair, then language, then the top level) decides it: its inline
875
+ // coachingPrompt, else its own coachingFile, read here (readOwnCoachingFile).
876
+ // A language's or pair's coachingFile used to lose to a less specific
877
+ // level's text, and never reached a plain LLM method at all.
878
+ for (const [pairKey, pc] of pairs) {
879
+ const own = rawSettings.get(pairKey)?.coaching || null;
880
+ pc._legacyCoachingPrompt = pc.coachingPrompt ?? null;
881
+ const ownFile = own && own.prompt == null && own.file != null ? own.file : null;
882
+ if (ownFile) {
883
+ pc.coachingPrompt = readOwnCoachingFile(ownFile, cwd, `${pairKey}: ${own.where}`);
884
+ pc._defaults?.delete('coachingPrompt');
885
+ }
886
+ pc._legacyCoachingSent = legacyCoachingSent(pc, pc._legacyCoachingPrompt, ownFile, cwd);
887
+ }
888
+
889
+ // Step 4: Fallbacks — the second method for what a pair's own method
890
+ // cannot translate safely. Resolved last so a fallback inherits the
891
+ // pair's final settings, script decision included. Errors name the pair.
892
+ for (const [pairKey, pc] of pairs) {
893
+ const rawFallback = rawSettings.get(pairKey)?.fallback;
894
+ if (rawFallback == null) continue;
895
+ const fallback = resolveFallbackForPair(pairKey, rawFallback, pc, config, cwd);
896
+ if (fallback) pc.fallback = fallback;
897
+ }
898
+
346
899
  return pairs;
347
900
  }
348
901
 
@@ -511,9 +1064,11 @@ function getPairForTarget(pairs, targetCode) {
511
1064
  *
512
1065
  * @param {number} keyCount - Number of keys to translate
513
1066
  * @param {object} pairConfig - Pair config with method and model
1067
+ * @param {{ cwd?: string }} [context] - cwd: the project directory (a local
1068
+ * method's endpoint may be set in its .env)
514
1069
  * @returns {{ estimatedCost: number|null, currency: string, source: string, note: string }}
515
1070
  */
516
- async function estimateCost(keyCount, pairConfig) {
1071
+ async function estimateCost(keyCount, pairConfig, context = {}) {
517
1072
  const methodName = pairConfig.method || 'llm';
518
1073
 
519
1074
  // Delegate to the method's own cost estimate.
@@ -522,7 +1077,7 @@ async function estimateCost(keyCount, pairConfig) {
522
1077
  // LLM varies by model, API is server-determined.
523
1078
  try {
524
1079
  const method = getMethod(methodName);
525
- return await method.estimateCost(keyCount, pairConfig);
1080
+ return await method.estimateCost(keyCount, pairConfig, context);
526
1081
  } catch {
527
1082
  // If method resolution fails, return an honest "unknown"
528
1083
  return {
@@ -536,6 +1091,7 @@ async function estimateCost(keyCount, pairConfig) {
536
1091
 
537
1092
  export {
538
1093
  resolvePairs,
1094
+ resolveFallbackForPair,
539
1095
  parsePairKey,
540
1096
  buildPairKey,
541
1097
  filterPairGraph,