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.
- package/README.md +41 -26
- package/bin/cli.js +53 -5
- package/index.js +63 -2
- package/lib/api-key.js +17 -4
- package/lib/autofix.js +83 -36
- package/lib/bridge/method_bridge.py +15 -3
- package/lib/cards/reader.js +34 -0
- package/lib/cards/remote.js +15 -0
- package/lib/cards/search-names.js +178 -0
- package/lib/command-help.js +286 -85
- package/lib/commands/audit.js +10 -3
- package/lib/commands/card.js +583 -226
- package/lib/commands/doctor.js +54 -18
- package/lib/commands/help.js +37 -32
- package/lib/commands/init.js +1689 -87
- package/lib/commands/integrity.js +127 -40
- package/lib/commands/leaderboard.js +187 -67
- package/lib/commands/models.js +9 -2
- package/lib/commands/provenance.js +7 -2
- package/lib/commands/recommend.js +43 -14
- package/lib/commands/register-corpus.js +632 -125
- package/lib/commands/seal-corpus.js +1 -1
- package/lib/commands/status.js +564 -27
- package/lib/commands/submit.js +17 -12
- package/lib/commands/sync.js +31 -7
- package/lib/commands/tm.js +15 -9
- package/lib/commands/verify.js +27 -3
- package/lib/commands/wrap.js +63 -5
- package/lib/commands/xliff.js +135 -64
- package/lib/commercial-eligibility.js +1 -1
- package/lib/config.js +196 -14
- package/lib/content-estimate.js +96 -0
- package/lib/content-refusals.js +270 -0
- package/lib/content-review.js +372 -0
- package/lib/content-sync.js +1127 -344
- package/lib/content.js +94 -7
- package/lib/corpus-registration.mjs +194 -35
- package/lib/cost-label.js +29 -0
- package/lib/cost-report.js +726 -78
- package/lib/diff.js +38 -4
- package/lib/docusaurus-sync.js +965 -253
- package/lib/edit-distance.js +31 -0
- package/lib/fallback.js +964 -0
- package/lib/file-scope.js +106 -0
- package/lib/flatten.js +80 -3
- package/lib/flutter-locales.js +124 -0
- package/lib/format.js +266 -12
- package/lib/hash.js +146 -21
- package/lib/icu-structure.js +929 -0
- package/lib/integrity.js +223 -75
- package/lib/language-pair.js +157 -0
- package/lib/lint.js +78 -16
- package/lib/local-only-marks.js +106 -0
- package/lib/locale-layout.js +1103 -0
- package/lib/locale-state.js +571 -0
- package/lib/methods/anthropic.js +5 -0
- package/lib/methods/apertium.js +6 -3
- package/lib/methods/api.js +138 -25
- package/lib/methods/base.js +17 -0
- package/lib/methods/coaching-data.js +153 -0
- package/lib/methods/content-separator.js +43 -0
- package/lib/methods/deepl.js +1 -1
- package/lib/methods/direct-llm.js +252 -103
- package/lib/methods/external.js +146 -63
- package/lib/methods/gemini.js +1 -0
- package/lib/methods/google-translate.js +1 -0
- package/lib/methods/http-utils.js +41 -0
- package/lib/methods/libretranslate.js +7 -2
- package/lib/methods/llm-coached.js +68 -128
- package/lib/methods/llm.js +80 -31
- package/lib/methods/local.js +93 -10
- package/lib/methods/microsoft-translator.js +1 -2
- package/lib/methods/openai.js +4 -2
- package/lib/methods/openrouter-client.js +20 -19
- package/lib/methods/openrouter-pricing.js +150 -13
- package/lib/methods/prompt-methods.js +20 -0
- package/lib/methods/provider-pricing.js +42 -1
- package/lib/methods/request-capture.js +104 -0
- package/lib/methods/tilde.js +1 -1
- package/lib/methods/translated.js +1 -2
- package/lib/missing-key.js +93 -0
- package/lib/models.js +11 -0
- package/lib/name-rules.js +32 -0
- package/lib/named-keys.js +172 -0
- package/lib/no-translate.js +4 -3
- package/lib/output.js +160 -19
- package/lib/pairs.js +586 -30
- package/lib/placeholders.js +394 -0
- package/lib/plugins.js +8 -0
- package/lib/plural-gap-redo.js +109 -0
- package/lib/plurals.js +323 -0
- package/lib/po.js +1187 -0
- package/lib/public-catalogue.js +74 -0
- package/lib/recommend.js +527 -32
- package/lib/redo.js +95 -0
- package/lib/refusal-category.js +44 -0
- package/lib/registers.js +255 -11
- package/lib/repair-script.js +20 -13
- package/lib/scripts.js +6 -1
- package/lib/seal.mjs +4 -3
- package/lib/sealed-qualifier.mjs +1 -1
- package/lib/segment.js +2 -1
- package/lib/seo.js +19 -9
- package/lib/serve.js +43 -6
- package/lib/shared-output-seed.js +164 -0
- package/lib/source-contexts.js +39 -0
- package/lib/submit.mjs +57 -5
- package/lib/sync.js +2923 -474
- package/lib/terminology.js +13 -4
- package/lib/tm-evict.js +179 -0
- package/lib/tm-seed.js +5 -2
- package/lib/tm.js +818 -36
- package/lib/translate-pair.js +639 -34
- package/lib/translate.js +78 -5
- package/lib/types.js +22 -3
- package/lib/validate.js +880 -17
- package/lib/verify.js +1296 -104
- package/lib/watch.js +32 -13
- package/lib/xliff.js +44 -3
- package/package.json +1 -1
- package/shared/CORPORA-CARDS.md +2 -0
- package/shared/cards-fallback.json +1 -1
- package/shared/curated-orthography-conventions.json +26 -8
- package/shared/gettext-plural-forms.json +45 -0
- package/shared/method-registry.json +2 -0
- package/shared/metric-registry.json +96 -18
- package/shared/schemas/champollion-plugin.schema.json +4 -0
- package/shared/schemas/corpora-card.schema.json +8 -2
- package/shared/schemas/method-index-record.schema.json +67 -0
- package/shared/schemas/method-registry.schema.json +4 -0
- package/shared/schemas/metric-registry.schema.json +55 -1
- 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
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
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,
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
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
|
-
//
|
|
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
|
|
195
|
-
* Normalizes a trailing slash and a full
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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.
|
|
220
|
-
* 2. Model
|
|
221
|
-
*
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
//
|
|
335
|
-
await this._validateModel(model, apiKey);
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const
|
|
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,
|
|
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
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
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
|
|
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 —
|
|
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,
|
|
438
|
-
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|