champollion 0.3.4 → 0.5.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 (138) 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 +82 -37
  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 +1709 -101
  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 +230 -17
  32. package/lib/content-estimate.js +96 -0
  33. package/lib/content-refusals.js +284 -0
  34. package/lib/content-review.js +372 -0
  35. package/lib/content-sync.js +1136 -345
  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 +727 -79
  40. package/lib/diff.js +38 -4
  41. package/lib/docusaurus-sync.js +975 -262
  42. package/lib/edit-distance.js +31 -0
  43. package/lib/fallback.js +1102 -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 +575 -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 +257 -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 +71 -129
  70. package/lib/methods/llm.js +87 -33
  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 +102 -55
  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 +24 -8
  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 +996 -21
  117. package/lib/verify.js +1332 -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/licenses.json +99 -3
  126. package/shared/method-registry.json +2 -0
  127. package/shared/metric-registry.json +96 -18
  128. package/shared/retired-model-aliases.json +9 -0
  129. package/shared/schemas/champollion-plugin.schema.json +4 -0
  130. package/shared/schemas/corpora-card.schema.json +8 -2
  131. package/shared/schemas/human-services.schema.json +1 -1
  132. package/shared/schemas/method-index-record.schema.json +67 -0
  133. package/shared/schemas/method-registry.schema.json +4 -0
  134. package/shared/schemas/metric-registry.schema.json +55 -1
  135. package/shared/schemas/retired-model-aliases.schema.json +32 -0
  136. package/shared/docent/corpus.json +0 -11739
  137. package/shared/model-aliases.json +0 -7
  138. package/shared/schemas/model-aliases.schema.json +0 -27
@@ -2,14 +2,21 @@
2
2
  * DirectLLMMethod — shared base class for direct LLM provider integrations.
3
3
  *
4
4
  * WHY THIS EXISTS:
5
- * OpenAI, Anthropic, and Gemini methods share ~90% of their logic:
6
- * - Constructor with coaching cache
7
- * - translate() with coaching loading, system message building, batch loop
8
- * - translateContent() with coaching block prepending
9
- * - Dictionary hint injection in per-batch user messages
5
+ * OpenAI, Anthropic, Gemini and Local methods share ~90% of their logic:
6
+ * - translate() with system message building and the batch loop
7
+ * - translateContent()
10
8
  * - JSON response parsing + key validation
11
9
  * - Retry loop with exponential backoff
12
10
  *
11
+ * THE PROMPT IS THE `llm` METHOD'S, exactly: promptSettingsFor() +
12
+ * buildSystemMessage() + buildUserMessage() from llm.js (register, gender
13
+ * guidance, prompt context, protected terms, coachingFile text, the
14
+ * project glossary, per-key instructions). These methods used to build a
15
+ * shorter one of their own — no protected terms, no prompt context, no
16
+ * gender guidance — so a brand name could be translated through `local`
17
+ * and kept through `llm` (found 2026-10-03). Structured coaching (grammar
18
+ * rules, style notes) is the llm-coached method's, on any provider.
19
+ *
13
20
  * The ONLY things that differ are:
14
21
  * - API endpoint URL + auth header format
15
22
  * - Request body shape (messages, system param, generationConfig)
@@ -20,10 +27,12 @@
20
27
  * set of abstract methods to provide provider-specific HTTP details.
21
28
  *
22
29
  * RUNTIME MODEL VALIDATION:
30
+ * An OpenRouter-style id ("openai/gpt-5.5") becomes
31
+ * the provider's own name, or is refused when the provider has none
32
+ * (resolveModelId — pairs.js applies it when the pair graph is built).
23
33
  * On first translate() call, the base class fetches the provider's available
24
34
  * model list and validates the configured model against it. This catches:
25
35
  * - Deprecated/retired model names (the exact bug we fixed twice already)
26
- * - OpenRouter-format model strings used with direct providers
27
36
  * - Models from the wrong provider (e.g., claude-* on OpenAI)
28
37
  * The model list is cached per-process to avoid repeated API calls.
29
38
  *
@@ -34,9 +43,11 @@
34
43
  */
35
44
 
36
45
  import path from 'node:path';
37
- import { LLMMethod, buildSystemMessage, buildUserMessage, isUnsafeKey, inferKeyTypes } from './llm.js';
38
- import { loadCoachingData, findDictionaryMatches, buildCoachedSystemMessage, buildContentCoachingBlock, DEFAULT_COACHING_DIR } from './llm-coached.js';
39
- import { getEnvOrFileVar } from '../api-key.js';
46
+ import { LLMMethod, buildSystemMessage, buildUserMessage, promptSettingsFor, isUnsafeKey } from './llm.js';
47
+ import { captureRequest, isCapturing, PREVIEW_KEY } from './request-capture.js';
48
+ import { projectGlossary } from './coaching-data.js';
49
+ import { requireExactModelId } from '../models.js';
50
+ import { getEnvOrFileVar, findEnvOrFileVar } from '../api-key.js';
40
51
  import {
41
52
  MAX_RETRIES,
42
53
  REQUEST_TIMEOUT_MS,
@@ -69,6 +80,16 @@ const MODEL_PATTERNS = {
69
80
  },
70
81
  };
71
82
 
83
+ /**
84
+ * The vendor segment of the OpenRouter-style ids each direct provider serves
85
+ * under its own names ("google/gemini-2.5-flash" → gemini "gemini-2.5-flash").
86
+ */
87
+ const DIRECT_MODEL_VENDORS = {
88
+ openai: 'openai',
89
+ anthropic: 'anthropic',
90
+ gemini: 'google',
91
+ };
92
+
72
93
  /**
73
94
  * Per-process cache of available models per provider.
74
95
  * Keyed by provider name (e.g., 'openai'), value is a Set of model IDs
@@ -79,8 +100,7 @@ const _modelListCache = new Map();
79
100
  class DirectLLMMethod extends LLMMethod {
80
101
  constructor(options = {}) {
81
102
  super(options);
82
- // Coaching data cache — avoids re-reading .champollion/coaching/<locale>.json per batch
83
- this._coachingCache = new Map();
103
+ // (this._coachingCache — the coaching-file cache — comes from LLMMethod.)
84
104
  // Track whether we've already validated the model for this instance
85
105
  this._modelValidated = false;
86
106
  }
@@ -167,7 +187,6 @@ class DirectLLMMethod extends LLMMethod {
167
187
  const envVar = this._getApiKeyEnvVar();
168
188
  const optKey = this._getApiKeyOptionsKey();
169
189
  return options[optKey]
170
- || getEnvOrFileVar(envVar)
171
190
  || getEnvOrFileVar(envVar, options.cwd);
172
191
  }
173
192
 
@@ -189,58 +208,200 @@ class DirectLLMMethod extends LLMMethod {
189
208
  */
190
209
  _getApiBaseEnvVar() { return 'OPENAI_API_BASE'; }
191
210
 
211
+ /**
212
+ * Every setting that names the endpoint base, in precedence order.
213
+ * OPENAI_BASE_URL is the OpenAI SDK's own name for OPENAI_API_BASE;
214
+ * people (and agents) reach for it first, and it used to be ignored
215
+ * silently — the request went to api.openai.com and failed with a 401
216
+ * that blamed the key (synthetic app-developer personas, 2026-10-03).
217
+ * @returns {string[]}
218
+ */
219
+ _getApiBaseEnvVars() {
220
+ const envVar = this._getApiBaseEnvVar();
221
+ return envVar === 'OPENAI_API_BASE' ? [envVar, 'OPENAI_BASE_URL'] : (envVar ? [envVar] : []);
222
+ }
223
+
224
+ /** How a message names the built-in default endpoint. */
225
+ _getDefaultApiBaseLabel() { return 'the built-in default'; }
226
+
192
227
  /**
193
228
  * Resolve the endpoint base (no /chat/completions). Precedence:
194
- * options.baseUrl / this.options.baseUrl > env > subclass default.
195
- * Normalizes a trailing slash and a full .../chat/completions path.
229
+ * options.baseUrl / this.options.baseUrl > env (see _getApiBaseEnvVars) >
230
+ * subclass default. Normalizes a trailing slash and a full
231
+ * .../chat/completions path.
196
232
  * @returns {string|null}
197
233
  */
198
234
  _resolveApiBase(options = {}) {
199
- const envVar = this._getApiBaseEnvVar();
200
- const fromEnv = envVar
201
- ? (getEnvOrFileVar(envVar) || getEnvOrFileVar(envVar, options.cwd))
202
- : null;
203
- const base = options.baseUrl
204
- || (this.options && this.options.baseUrl)
205
- || fromEnv
206
- || this._getDefaultApiBase();
207
- if (!base) return null;
235
+ return this._resolveApiBaseSource(options).base;
236
+ }
237
+
238
+ /**
239
+ * The endpoint base AND the setting it came from, so a failure can say
240
+ * "could not reach http://localhost:8000/v1 (LOCAL_API_BASE in .env)"
241
+ * instead of a bare "fetch failed" (synthetic i18next persona, 2026-10).
242
+ *
243
+ * @param {{ baseUrl?: string, cwd?: string }} [options]
244
+ * @returns {{ base: string|null, from: string|null }}
245
+ */
246
+ _resolveApiBaseSource(options = {}) {
247
+ let base = null;
248
+ let from = null;
249
+ if (options.baseUrl) {
250
+ base = options.baseUrl;
251
+ from = 'the baseUrl option';
252
+ } else if (this.options && this.options.baseUrl) {
253
+ base = this.options.baseUrl;
254
+ from = 'the baseUrl setting';
255
+ } else {
256
+ for (const name of this._getApiBaseEnvVars()) {
257
+ const found = findEnvOrFileVar(name, options.cwd);
258
+ if (found) {
259
+ base = found.value;
260
+ from = found.origin === 'environment' ? name : `${name} in ${path.relative(process.cwd(), found.file) || found.origin}`;
261
+ break;
262
+ }
263
+ }
264
+ if (!base) {
265
+ base = this._getDefaultApiBase();
266
+ from = base ? this._getDefaultApiBaseLabel() : null;
267
+ }
268
+ }
269
+ if (!base) return { base: null, from: null };
208
270
  let b = String(base).replace(/\/+$/, '');
209
271
  if (b.endsWith('/chat/completions')) {
210
272
  b = b.slice(0, -'/chat/completions'.length);
211
273
  }
212
- return b;
274
+ return { base: b, from };
275
+ }
276
+
277
+ /**
278
+ * Where this method sends its requests, for a failure message:
279
+ * "http://127.0.0.1:9/v1 (from LOCAL_API_BASE)". Origin and path only —
280
+ * a query string can carry a key (Gemini). null for a provider whose
281
+ * requests do not go through a configurable base (it has no default one:
282
+ * Anthropic and Gemini call their own fixed URLs).
283
+ * @param {object} [options]
284
+ * @returns {string|null}
285
+ */
286
+ _describeEndpoint(options = {}) {
287
+ if (!this._getDefaultApiBase()) return null;
288
+ let resolved;
289
+ try { resolved = this._resolveApiBaseSource(options); } catch { return null; }
290
+ if (!resolved.base) return null;
291
+ let shown = resolved.base;
292
+ try {
293
+ const u = new URL(resolved.base);
294
+ shown = `${u.origin}${u.pathname.replace(/\/+$/, '')}`;
295
+ } catch { /* not a URL — show it as written */ }
296
+ return resolved.from ? `${shown} (from ${resolved.from})` : shown;
297
+ }
298
+
299
+ // ── Model ids: the provider's own names, never an OpenRouter slug ──
300
+
301
+ /**
302
+ * The vendor segment of the OpenRouter-style ids ("openai/gpt-4o") this
303
+ * provider serves under its own names. null = no vendor (a local server
304
+ * serves whatever names it was given).
305
+ * @returns {string|null}
306
+ */
307
+ _getModelVendor() { return null; }
308
+
309
+ /**
310
+ * An id after the vendor segment, as this provider's API names it.
311
+ * Override where the two differ (Anthropic: dotted versions).
312
+ * @param {string} name
313
+ * @returns {string}
314
+ */
315
+ _nativeModelName(name) { return name; }
316
+
317
+ /**
318
+ * True when requests go to the provider's own API, so model ids are its
319
+ * names. False for an OpenAI-compatible gateway or server set through
320
+ * OPENAI_API_BASE / LOCAL_API_BASE (Groq, Together, Ollama…): any id —
321
+ * "meta-llama/Llama-3.3-70B" included — is that server's to judge.
322
+ * @returns {boolean}
323
+ */
324
+ _callsOwnApi(cwd = null) {
325
+ if (this._getModelVendor() === null) return false;
326
+ const def = this._getDefaultApiBase();
327
+ if (!def) return true; // a fixed URL (Anthropic, Gemini)
328
+ try { return this._resolveApiBase(cwd ? { cwd } : {}) === def.replace(/\/+$/, ''); } catch { return true; }
329
+ }
330
+
331
+ /**
332
+ * The model id this provider is sent, for a configured model:
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);
337
+ * - an OpenRouter-style id of THIS provider's vendor becomes its own
338
+ * name ("openai/gpt-5.5" → "gpt-5.5", "anthropic/claude-haiku-4.5" →
339
+ * "claude-haiku-4-5") — mirroring the harness's direct providers;
340
+ * - an id of another vendor ("google/gemini-2.5-flash" for openai) has
341
+ * no name here: REFUSED, naming a model this method can run. It used to
342
+ * be sent as is, with a warning, and every request failed.
343
+ * A gateway or local server (see _callsOwnApi) gets the id as written.
344
+ *
345
+ * @param {string|null} model
346
+ * @param {{ from?: string, cwd?: string }} [where] - Where the id was set, for the refusal
347
+ * ("from --model", "from the top-level \"model\""); cwd: the project
348
+ * directory (its .env may point the method at a gateway)
349
+ * @returns {string|null}
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
352
+ */
353
+ resolveModelId(model, { from = null, cwd = null } = {}) {
354
+ if (!model || typeof model !== 'string') return model;
355
+ requireExactModelId(model, { from });
356
+ if (!this._callsOwnApi(cwd)) return model;
357
+ const vendor = this._getModelVendor();
358
+ const resolved = model;
359
+ const slash = resolved.indexOf('/');
360
+ if (slash < 0) return this._nativeModelName(resolved);
361
+ const idVendor = resolved.slice(0, slash);
362
+ const name = resolved.slice(slash + 1);
363
+ const plainName = /^[A-Za-z0-9._-]+$/.test(name);
364
+ if (idVendor === vendor && plainName) return this._nativeModelName(name);
365
+
366
+ const notes = [from].filter(Boolean);
367
+ const what = `"${model}"${notes.length > 0 ? ` (${notes.join(', ')})` : ''}`;
368
+ const servedBy = Object.entries(DIRECT_MODEL_VENDORS).find(([, v]) => v === idVendor)?.[0] || null;
369
+ const label = this._getProviderLabel();
370
+ const ways = [`${/^[aeiou]/i.test(label) ? 'an' : 'a'} ${label} model (e.g. --model ${this._getDefaultModel()})`];
371
+ if (servedBy && servedBy !== this.name && plainName) ways.push(`--method ${servedBy} (its name for it: "${name}")`);
372
+ ways.push('--method llm to run it through OpenRouter');
373
+ const err = new Error(
374
+ `model ${what} is an OpenRouter model id — ${this.name} calls ${label} directly, `
375
+ + `which has no model by that name. Use ${ways.slice(0, -1).join(', ')} or ${ways[ways.length - 1]}.`
376
+ );
377
+ err.code = 'CHAMPOLLION_MODEL_ROUTE';
378
+ throw err;
213
379
  }
214
380
 
215
381
  /**
216
382
  * Validate the model string before making API calls.
217
383
  *
218
384
  * Checks:
219
- * 1. OpenRouter-format model strings (contain '/') — wrong method
220
- * 2. Model belongs to a different provider (e.g., claude-* on OpenAI)
221
- * 3. Model exists in the provider's API (runtime fetch, cached)
385
+ * 1. Model belongs to a different provider (e.g., claude-* on OpenAI)
386
+ * 2. Model exists in the provider's API (runtime fetch, cached)
387
+ * (An OpenRouter-style id never gets here: resolveModelId maps or refuses it.)
222
388
  *
223
389
  * Logs warnings but does NOT block — the provider API will give
224
- * the definitive answer. This is a DX aid, not a gate.
390
+ * the definitive answer. This is a DX aid, not a gate. Skipped for a
391
+ * gateway or local server: its names are its own, and its key must not
392
+ * be sent to the provider's model list.
225
393
  *
226
394
  * @param {string} model - Model ID to validate
227
395
  * @param {string} apiKey - API key for model list fetch
228
396
  */
229
- async _validateModel(model, apiKey) {
397
+ async _validateModel(model, apiKey, cwd = null) {
230
398
  if (this._modelValidated) return;
231
399
  this._modelValidated = true;
400
+ if (!this._callsOwnApi(cwd)) return;
232
401
 
233
402
  const label = this._getProviderLabel();
234
403
 
235
- // Check 1: OpenRouter-format model string (contains '/')
236
- if (model.includes('/')) {
237
- output.warn(`${label}: model "${model}" looks like an OpenRouter path.`);
238
- output.warn(`Direct providers use bare model names (e.g., "${this._getDefaultModel()}").`);
239
- output.warn(`To use OpenRouter models, set method to 'llm' instead.`);
240
- return;
241
- }
242
-
243
- // Check 2: Model belongs to a different provider
404
+ // Check 1: Model belongs to a different provider
244
405
  for (const [provider, { prefixes, label: providerLabel }] of Object.entries(MODEL_PATTERNS)) {
245
406
  if (provider === this.name) continue; // skip own provider
246
407
  const matchesOther = prefixes.some(p => model.startsWith(p));
@@ -253,7 +414,7 @@ class DirectLLMMethod extends LLMMethod {
253
414
  }
254
415
  }
255
416
 
256
- // Check 3: Runtime model list validation (cached per-process)
417
+ // Check 2: Runtime model list validation (cached per-process)
257
418
  try {
258
419
  let modelSet = _modelListCache.get(this.name);
259
420
 
@@ -316,7 +477,8 @@ class DirectLLMMethod extends LLMMethod {
316
477
  // ── Core translate() — shared across all direct LLM providers ──
317
478
 
318
479
  async translate(keys, sourceFlat, pairConfig, options) {
319
- const apiKey = this._resolveApiKey(options);
480
+ // A request preview needs no key: it is shown, never sent.
481
+ const apiKey = this._resolveApiKey(options) || (isCapturing() ? PREVIEW_KEY : null);
320
482
 
321
483
  if (!apiKey) {
322
484
  output.warn(`${this._getProviderLabel()}: no API key — skipping.`);
@@ -324,33 +486,30 @@ class DirectLLMMethod extends LLMMethod {
324
486
  }
325
487
 
326
488
  const batchSize = pairConfig.batchSize || options.batchSize || DEFAULT_BATCH_SIZE;
327
- const model = pairConfig.model || options.model || this._getDefaultModel();
489
+ // This provider's own name for the model (an OpenRouter slug is mapped
490
+ // or refused — pairs.js does the same when the pair graph is built).
491
+ // The project directory (its .env, .env.local): options.cwd, never
492
+ // whatever process.cwd() happens to be (the MCP server runs elsewhere).
493
+ const cwd = options.cwd || null;
494
+ const model = this.resolveModelId(pairConfig.model || options.model || this._getDefaultModel(), { cwd });
328
495
  const maxRetries = pairConfig.maxRetries ?? DEFAULT_MAX_RETRIES;
329
- const langConfig = {
330
- name: pairConfig.name,
331
- register: pairConfig.register,
332
- };
333
-
334
- // Validate model on first call (logs warnings, does not block)
335
- await this._validateModel(model, apiKey);
336
-
337
- // Load coaching data if available (.champollion/coaching/<locale>.json)
338
- const cwd = options.cwd || process.cwd();
339
- const coachingDir = path.join(cwd, DEFAULT_COACHING_DIR);
340
- const coaching = loadCoachingData(coachingDir, pairConfig.target, this._coachingCache);
341
-
342
- // If coaching data exists, use the coached system message (grammar/style in system
343
- // prompt for provider-level caching). Otherwise use the standard system message.
344
- const systemMessage = coaching
345
- ? buildCoachedSystemMessage(langConfig, coaching)
346
- : buildSystemMessage(langConfig);
496
+ // The `llm` method's prompt, exactly (llm.js promptSettingsFor): only
497
+ // the transport is this provider's.
498
+ const langConfig = promptSettingsFor(pairConfig);
499
+
500
+ // Validate model on first call (logs warnings, does not block). It lists
501
+ // the provider's models over the network — not while showing a request.
502
+ if (!isCapturing()) await this._validateModel(model, apiKey, cwd);
503
+
504
+ const systemMessage = buildSystemMessage(langConfig);
505
+ // The project glossary: each batch is told the terms it contains.
506
+ const glossary = projectGlossary(pairConfig, options, this._coachingCache);
347
507
  const allTranslated = {};
348
508
 
349
- // Wrap the batch function to inject dictionary hints when coaching is active.
350
509
  // Thread the resolved temperature so _callProviderBatch doesn't need pairConfig.
351
510
  const resolvedTemperature = pairConfig.temperature ?? DEFAULT_TEMPERATURE;
352
511
  const descriptions = options.descriptions || null;
353
- const batchFn = (batch, opts) => this._callProviderBatch(batch, { ...opts, apiKey, model, coaching, temperature: resolvedTemperature, descriptions });
512
+ const batchFn = (batch, opts) => this._callProviderBatch(batch, { ...opts, apiKey, model, glossary, temperature: resolvedTemperature, descriptions, cwd });
354
513
 
355
514
  const batchChunks = [];
356
515
  for (let i = 0; i < keys.length; i += batchSize) {
@@ -389,29 +548,23 @@ class DirectLLMMethod extends LLMMethod {
389
548
  return null;
390
549
  }
391
550
 
392
- const model = pairConfig.model || options.model || this._getDefaultModel();
393
-
394
- // Prepend coaching context (grammar/style rules) to content prompts when available.
395
- // Dictionary matching is skipped for freeform content — it's too unpredictable.
396
- const cwd = options.cwd || process.cwd();
397
- const coachingDir = path.join(cwd, DEFAULT_COACHING_DIR);
398
- const coaching = loadCoachingData(coachingDir, pairConfig.target, this._coachingCache);
399
- let augmentedPrompt = prompt;
400
- if (coaching) {
401
- const block = buildContentCoachingBlock(coaching);
402
- if (block) {
403
- augmentedPrompt = block + '\n\n' + prompt;
404
- }
405
- }
551
+ const cwd = options.cwd || null;
552
+ const model = this.resolveModelId(pairConfig.model || options.model || this._getDefaultModel(), { cwd });
553
+
554
+ // The content prompt is built by the lane (lib/content.js,
555
+ // lib/segment.js), the same for every method, and sent as is — exactly
556
+ // as `llm` sends it. Coaching (grammar rules, style notes) is
557
+ // llm-coached's: its translateContent prepends it for any provider.
406
558
 
407
559
  // Round 1: standard timeout (2× base = 60s)
408
560
  const result = await this._callProviderDirect({
409
- prompt: augmentedPrompt,
561
+ prompt,
410
562
  apiKey,
411
563
  model,
412
564
  temperature: pairConfig.temperature ?? DEFAULT_TEMPERATURE,
413
565
  timeoutMs: REQUEST_TIMEOUT_MS * 2,
414
566
  label: `${this._getProviderLabel()} Content`,
567
+ cwd,
415
568
  });
416
569
 
417
570
  if (result) return result;
@@ -422,41 +575,27 @@ class DirectLLMMethod extends LLMMethod {
422
575
  await sleep(10_000);
423
576
 
424
577
  return this._callProviderDirect({
425
- prompt: augmentedPrompt,
578
+ prompt,
426
579
  apiKey,
427
580
  model,
428
581
  temperature: pairConfig.temperature ?? DEFAULT_TEMPERATURE,
429
582
  timeoutMs: REQUEST_TIMEOUT_MS * 4,
430
583
  label,
584
+ cwd,
431
585
  });
432
586
  }
433
587
 
434
- // ── Shared batch call — builds user message with coaching hints ──
588
+ // ── Shared batch call — the `llm` user message, this provider's transport ──
435
589
 
436
590
  async _callProviderBatch(toTranslate, options) {
437
- const { apiKey, model, batchNum, systemMessage, coaching, temperature } = options;
438
-
439
- // Build user message — inject dictionary term matches when coaching is active.
440
- // Dictionary hints go in the user message (per-batch) rather than the system
441
- // message (cached) because they're specific to the current batch's source values.
442
- let prompt;
443
- if (coaching && coaching.dictionary) {
444
- const dictHints = findDictionaryMatches(toTranslate, coaching.dictionary);
445
- const typeHints = inferKeyTypes(toTranslate);
446
- let userMessage = '';
447
- if (dictHints.length > 0) {
448
- userMessage += 'REQUIRED TERMINOLOGY (use these exact translations):\n';
449
- userMessage += dictHints.map(h => ` • "${h.term}" → "${h.translation}"`).join('\n');
450
- userMessage += '\n\n';
451
- }
452
- if (typeHints.length > 0) {
453
- userMessage += `UI context for these keys:\n${typeHints.join('\n')}\n\n`;
454
- }
455
- userMessage += JSON.stringify(toTranslate, null, 2);
456
- prompt = userMessage;
457
- } else {
458
- prompt = buildUserMessage(toTranslate, options.descriptions || undefined);
459
- }
591
+ const { apiKey, model, batchNum, systemMessage, temperature } = options;
592
+
593
+ // The user message every LLM method sends (llm.js buildUserMessage):
594
+ // the glossary terms this batch contains, then the per-key instructions
595
+ // (plural forms, ICU categories, gettext context, a gate retry's
596
+ // feedback), then the batch. Glossary hints go here (per batch), not in
597
+ // the cached system message, because they depend on the batch's values.
598
+ const prompt = buildUserMessage(toTranslate, options.descriptions || undefined, options.glossary || null);
460
599
 
461
600
  const label = this._getProviderLabel();
462
601
  const content = await this._callProviderDirect({
@@ -467,6 +606,7 @@ class DirectLLMMethod extends LLMMethod {
467
606
  temperature: temperature ?? DEFAULT_TEMPERATURE,
468
607
  label: `${label} Batch ${batchNum}`,
469
608
  isJsonMode: true,
609
+ cwd: options.cwd || null,
470
610
  });
471
611
 
472
612
  if (!content) return null;
@@ -498,9 +638,16 @@ class DirectLLMMethod extends LLMMethod {
498
638
  timeoutMs = REQUEST_TIMEOUT_MS,
499
639
  label,
500
640
  isJsonMode = false,
641
+ cwd = null,
501
642
  }) {
502
643
  label = label || this._getProviderLabel();
503
644
 
645
+ // `sync --dry --show-prompt`: hand over the exact request, send nothing.
646
+ if (isCapturing()) {
647
+ captureRequest(this._buildApiRequest({ prompt, systemMessage, apiKey, model, temperature, isJsonMode, cwd }));
648
+ return null;
649
+ }
650
+
504
651
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
505
652
  try {
506
653
  const controller = new AbortController();
@@ -513,6 +660,7 @@ class DirectLLMMethod extends LLMMethod {
513
660
  model,
514
661
  temperature,
515
662
  isJsonMode,
663
+ cwd,
516
664
  });
517
665
 
518
666
  const response = await fetch(url, {
@@ -562,7 +710,10 @@ class DirectLLMMethod extends LLMMethod {
562
710
 
563
711
  } catch (err) {
564
712
  const isTimeout = err.name === 'AbortError';
565
- const errLabel = isTimeout ? 'timeout' : err.message;
713
+ // A connection failure is "fetch failed" with the real reason
714
+ // (ECONNREFUSED, ENOTFOUND…) on err.cause.
715
+ const cause = err && err.cause && (err.cause.code || err.cause.message);
716
+ const errLabel = isTimeout ? 'timeout' : `${err.message}${cause && !String(err.message).includes(cause) ? ` (${cause})` : ''}`;
566
717
 
567
718
  if (attempt < MAX_RETRIES) {
568
719
  const delay = getBackoffDelay(attempt);
@@ -571,7 +722,9 @@ class DirectLLMMethod extends LLMMethod {
571
722
  continue;
572
723
  }
573
724
 
574
- output.error(`${label} failed: ${errLabel}`);
725
+ // Say WHERE it tried, and which setting chose that address.
726
+ const endpoint = this._describeEndpoint(cwd ? { cwd } : {});
727
+ output.error(`${label} failed: ${errLabel}${endpoint ? ` — ${isTimeout ? 'no answer from' : 'could not reach'} ${endpoint}` : ''}`);
575
728
  return null;
576
729
  }
577
730
  }
@@ -582,5 +735,6 @@ class DirectLLMMethod extends LLMMethod {
582
735
  export {
583
736
  DirectLLMMethod,
584
737
  MODEL_PATTERNS,
738
+ DIRECT_MODEL_VENDORS,
585
739
  _modelListCache,
586
740
  };