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.
- 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 +82 -37
- 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 +1709 -101
- 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 +230 -17
- package/lib/content-estimate.js +96 -0
- package/lib/content-refusals.js +284 -0
- package/lib/content-review.js +372 -0
- package/lib/content-sync.js +1136 -345
- 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 +727 -79
- package/lib/diff.js +38 -4
- package/lib/docusaurus-sync.js +975 -262
- package/lib/edit-distance.js +31 -0
- package/lib/fallback.js +1102 -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 +575 -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 +257 -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 +71 -129
- package/lib/methods/llm.js +87 -33
- 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 +102 -55
- 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 +24 -8
- 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 +996 -21
- package/lib/verify.js +1332 -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/licenses.json +99 -3
- package/shared/method-registry.json +2 -0
- package/shared/metric-registry.json +96 -18
- package/shared/retired-model-aliases.json +9 -0
- package/shared/schemas/champollion-plugin.schema.json +4 -0
- package/shared/schemas/corpora-card.schema.json +8 -2
- package/shared/schemas/human-services.schema.json +1 -1
- 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/schemas/retired-model-aliases.schema.json +32 -0
- package/shared/docent/corpus.json +0 -11739
- package/shared/model-aliases.json +0 -7
- 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
|
|
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") 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 { 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
|
-
//
|
|
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
|
|
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
|
+
* - 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.
|
|
220
|
-
* 2. Model
|
|
221
|
-
*
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
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,
|
|
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
|
|
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
|
-
}
|
|
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
|
|
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
|
|
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 —
|
|
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,
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|