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
@@ -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", or an alias for one) 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 { resolveModel } 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,195 @@ 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
+ * - an alias from shared/model-aliases.json resolves first
334
+ * ("gpt" → "openai/gpt-5.5");
335
+ * - an OpenRouter-style id of THIS provider's vendor becomes its own
336
+ * name ("openai/gpt-5.5" → "gpt-5.5", "anthropic/claude-haiku-4.5" →
337
+ * "claude-haiku-4-5") — mirroring the harness's direct providers;
338
+ * - an id of another vendor ("google/gemini-2.5-flash" for openai) has
339
+ * no name here: REFUSED, naming a model this method can run. It used to
340
+ * be sent as is, with a warning, and every request failed.
341
+ * A gateway or local server (see _callsOwnApi) gets the id as written.
342
+ *
343
+ * @param {string|null} model
344
+ * @param {{ from?: string, cwd?: string }} [where] - Where the id was set, for the refusal
345
+ * ("from --model", "from the top-level \"model\""); cwd: the project
346
+ * directory (its .env may point the method at a gateway)
347
+ * @returns {string|null}
348
+ * @throws {Error} code CHAMPOLLION_MODEL_ROUTE when there is no such model here
349
+ */
350
+ resolveModelId(model, { from = null, cwd = null } = {}) {
351
+ if (!model || typeof model !== 'string' || !this._callsOwnApi(cwd)) return model;
352
+ const vendor = this._getModelVendor();
353
+ const resolved = resolveModel(model);
354
+ const slash = resolved.indexOf('/');
355
+ if (slash < 0) return this._nativeModelName(resolved);
356
+ const idVendor = resolved.slice(0, slash);
357
+ const name = resolved.slice(slash + 1);
358
+ const plainName = /^[A-Za-z0-9._-]+$/.test(name);
359
+ if (idVendor === vendor && plainName) return this._nativeModelName(name);
360
+
361
+ const notes = [resolved === model ? null : `alias of ${resolved}`, from].filter(Boolean);
362
+ const what = `"${model}"${notes.length > 0 ? ` (${notes.join(', ')})` : ''}`;
363
+ const servedBy = Object.entries(DIRECT_MODEL_VENDORS).find(([, v]) => v === idVendor)?.[0] || null;
364
+ const label = this._getProviderLabel();
365
+ const ways = [`${/^[aeiou]/i.test(label) ? 'an' : 'a'} ${label} model (e.g. --model ${this._getDefaultModel()})`];
366
+ if (servedBy && servedBy !== this.name && plainName) ways.push(`--method ${servedBy} (its name for it: "${name}")`);
367
+ ways.push('--method llm to run it through OpenRouter');
368
+ const err = new Error(
369
+ `model ${what} is an OpenRouter model id — ${this.name} calls ${label} directly, `
370
+ + `which has no model by that name. Use ${ways.slice(0, -1).join(', ')} or ${ways[ways.length - 1]}.`
371
+ );
372
+ err.code = 'CHAMPOLLION_MODEL_ROUTE';
373
+ throw err;
213
374
  }
214
375
 
215
376
  /**
216
377
  * Validate the model string before making API calls.
217
378
  *
218
379
  * 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)
380
+ * 1. Model belongs to a different provider (e.g., claude-* on OpenAI)
381
+ * 2. Model exists in the provider's API (runtime fetch, cached)
382
+ * (An OpenRouter-style id never gets here: resolveModelId maps or refuses it.)
222
383
  *
223
384
  * Logs warnings but does NOT block — the provider API will give
224
- * the definitive answer. This is a DX aid, not a gate.
385
+ * the definitive answer. This is a DX aid, not a gate. Skipped for a
386
+ * gateway or local server: its names are its own, and its key must not
387
+ * be sent to the provider's model list.
225
388
  *
226
389
  * @param {string} model - Model ID to validate
227
390
  * @param {string} apiKey - API key for model list fetch
228
391
  */
229
- async _validateModel(model, apiKey) {
392
+ async _validateModel(model, apiKey, cwd = null) {
230
393
  if (this._modelValidated) return;
231
394
  this._modelValidated = true;
395
+ if (!this._callsOwnApi(cwd)) return;
232
396
 
233
397
  const label = this._getProviderLabel();
234
398
 
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
399
+ // Check 1: Model belongs to a different provider
244
400
  for (const [provider, { prefixes, label: providerLabel }] of Object.entries(MODEL_PATTERNS)) {
245
401
  if (provider === this.name) continue; // skip own provider
246
402
  const matchesOther = prefixes.some(p => model.startsWith(p));
@@ -253,7 +409,7 @@ class DirectLLMMethod extends LLMMethod {
253
409
  }
254
410
  }
255
411
 
256
- // Check 3: Runtime model list validation (cached per-process)
412
+ // Check 2: Runtime model list validation (cached per-process)
257
413
  try {
258
414
  let modelSet = _modelListCache.get(this.name);
259
415
 
@@ -316,7 +472,8 @@ class DirectLLMMethod extends LLMMethod {
316
472
  // ── Core translate() — shared across all direct LLM providers ──
317
473
 
318
474
  async translate(keys, sourceFlat, pairConfig, options) {
319
- const apiKey = this._resolveApiKey(options);
475
+ // A request preview needs no key: it is shown, never sent.
476
+ const apiKey = this._resolveApiKey(options) || (isCapturing() ? PREVIEW_KEY : null);
320
477
 
321
478
  if (!apiKey) {
322
479
  output.warn(`${this._getProviderLabel()}: no API key — skipping.`);
@@ -324,33 +481,30 @@ class DirectLLMMethod extends LLMMethod {
324
481
  }
325
482
 
326
483
  const batchSize = pairConfig.batchSize || options.batchSize || DEFAULT_BATCH_SIZE;
327
- const model = pairConfig.model || options.model || this._getDefaultModel();
484
+ // This provider's own name for the model (an OpenRouter slug is mapped
485
+ // or refused — pairs.js does the same when the pair graph is built).
486
+ // The project directory (its .env, .env.local): options.cwd, never
487
+ // whatever process.cwd() happens to be (the MCP server runs elsewhere).
488
+ const cwd = options.cwd || null;
489
+ const model = this.resolveModelId(pairConfig.model || options.model || this._getDefaultModel(), { cwd });
328
490
  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);
491
+ // The `llm` method's prompt, exactly (llm.js promptSettingsFor): only
492
+ // the transport is this provider's.
493
+ const langConfig = promptSettingsFor(pairConfig);
494
+
495
+ // Validate model on first call (logs warnings, does not block). It lists
496
+ // the provider's models over the network — not while showing a request.
497
+ if (!isCapturing()) await this._validateModel(model, apiKey, cwd);
498
+
499
+ const systemMessage = buildSystemMessage(langConfig);
500
+ // The project glossary: each batch is told the terms it contains.
501
+ const glossary = projectGlossary(pairConfig, options, this._coachingCache);
347
502
  const allTranslated = {};
348
503
 
349
- // Wrap the batch function to inject dictionary hints when coaching is active.
350
504
  // Thread the resolved temperature so _callProviderBatch doesn't need pairConfig.
351
505
  const resolvedTemperature = pairConfig.temperature ?? DEFAULT_TEMPERATURE;
352
506
  const descriptions = options.descriptions || null;
353
- const batchFn = (batch, opts) => this._callProviderBatch(batch, { ...opts, apiKey, model, coaching, temperature: resolvedTemperature, descriptions });
507
+ const batchFn = (batch, opts) => this._callProviderBatch(batch, { ...opts, apiKey, model, glossary, temperature: resolvedTemperature, descriptions, cwd });
354
508
 
355
509
  const batchChunks = [];
356
510
  for (let i = 0; i < keys.length; i += batchSize) {
@@ -389,29 +543,23 @@ class DirectLLMMethod extends LLMMethod {
389
543
  return null;
390
544
  }
391
545
 
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
- }
546
+ const cwd = options.cwd || null;
547
+ const model = this.resolveModelId(pairConfig.model || options.model || this._getDefaultModel(), { cwd });
548
+
549
+ // The content prompt is built by the lane (lib/content.js,
550
+ // lib/segment.js), the same for every method, and sent as is — exactly
551
+ // as `llm` sends it. Coaching (grammar rules, style notes) is
552
+ // llm-coached's: its translateContent prepends it for any provider.
406
553
 
407
554
  // Round 1: standard timeout (2× base = 60s)
408
555
  const result = await this._callProviderDirect({
409
- prompt: augmentedPrompt,
556
+ prompt,
410
557
  apiKey,
411
558
  model,
412
559
  temperature: pairConfig.temperature ?? DEFAULT_TEMPERATURE,
413
560
  timeoutMs: REQUEST_TIMEOUT_MS * 2,
414
561
  label: `${this._getProviderLabel()} Content`,
562
+ cwd,
415
563
  });
416
564
 
417
565
  if (result) return result;
@@ -422,41 +570,27 @@ class DirectLLMMethod extends LLMMethod {
422
570
  await sleep(10_000);
423
571
 
424
572
  return this._callProviderDirect({
425
- prompt: augmentedPrompt,
573
+ prompt,
426
574
  apiKey,
427
575
  model,
428
576
  temperature: pairConfig.temperature ?? DEFAULT_TEMPERATURE,
429
577
  timeoutMs: REQUEST_TIMEOUT_MS * 4,
430
578
  label,
579
+ cwd,
431
580
  });
432
581
  }
433
582
 
434
- // ── Shared batch call — builds user message with coaching hints ──
583
+ // ── Shared batch call — the `llm` user message, this provider's transport ──
435
584
 
436
585
  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
- }
586
+ const { apiKey, model, batchNum, systemMessage, temperature } = options;
587
+
588
+ // The user message every LLM method sends (llm.js buildUserMessage):
589
+ // the glossary terms this batch contains, then the per-key instructions
590
+ // (plural forms, ICU categories, gettext context, a gate retry's
591
+ // feedback), then the batch. Glossary hints go here (per batch), not in
592
+ // the cached system message, because they depend on the batch's values.
593
+ const prompt = buildUserMessage(toTranslate, options.descriptions || undefined, options.glossary || null);
460
594
 
461
595
  const label = this._getProviderLabel();
462
596
  const content = await this._callProviderDirect({
@@ -467,6 +601,7 @@ class DirectLLMMethod extends LLMMethod {
467
601
  temperature: temperature ?? DEFAULT_TEMPERATURE,
468
602
  label: `${label} Batch ${batchNum}`,
469
603
  isJsonMode: true,
604
+ cwd: options.cwd || null,
470
605
  });
471
606
 
472
607
  if (!content) return null;
@@ -498,9 +633,16 @@ class DirectLLMMethod extends LLMMethod {
498
633
  timeoutMs = REQUEST_TIMEOUT_MS,
499
634
  label,
500
635
  isJsonMode = false,
636
+ cwd = null,
501
637
  }) {
502
638
  label = label || this._getProviderLabel();
503
639
 
640
+ // `sync --dry --show-prompt`: hand over the exact request, send nothing.
641
+ if (isCapturing()) {
642
+ captureRequest(this._buildApiRequest({ prompt, systemMessage, apiKey, model, temperature, isJsonMode, cwd }));
643
+ return null;
644
+ }
645
+
504
646
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
505
647
  try {
506
648
  const controller = new AbortController();
@@ -513,6 +655,7 @@ class DirectLLMMethod extends LLMMethod {
513
655
  model,
514
656
  temperature,
515
657
  isJsonMode,
658
+ cwd,
516
659
  });
517
660
 
518
661
  const response = await fetch(url, {
@@ -562,7 +705,10 @@ class DirectLLMMethod extends LLMMethod {
562
705
 
563
706
  } catch (err) {
564
707
  const isTimeout = err.name === 'AbortError';
565
- const errLabel = isTimeout ? 'timeout' : err.message;
708
+ // A connection failure is "fetch failed" with the real reason
709
+ // (ECONNREFUSED, ENOTFOUND…) on err.cause.
710
+ const cause = err && err.cause && (err.cause.code || err.cause.message);
711
+ const errLabel = isTimeout ? 'timeout' : `${err.message}${cause && !String(err.message).includes(cause) ? ` (${cause})` : ''}`;
566
712
 
567
713
  if (attempt < MAX_RETRIES) {
568
714
  const delay = getBackoffDelay(attempt);
@@ -571,7 +717,9 @@ class DirectLLMMethod extends LLMMethod {
571
717
  continue;
572
718
  }
573
719
 
574
- output.error(`${label} failed: ${errLabel}`);
720
+ // Say WHERE it tried, and which setting chose that address.
721
+ const endpoint = this._describeEndpoint(cwd ? { cwd } : {});
722
+ output.error(`${label} failed: ${errLabel}${endpoint ? ` — ${isTimeout ? 'no answer from' : 'could not reach'} ${endpoint}` : ''}`);
575
723
  return null;
576
724
  }
577
725
  }
@@ -582,5 +730,6 @@ class DirectLLMMethod extends LLMMethod {
582
730
  export {
583
731
  DirectLLMMethod,
584
732
  MODEL_PATTERNS,
733
+ DIRECT_MODEL_VENDORS,
585
734
  _modelListCache,
586
735
  };