champollion 0.3.3
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/LICENSE +133 -0
- package/README.md +387 -0
- package/bin/cli.js +278 -0
- package/index.js +135 -0
- package/lib/api-key.js +127 -0
- package/lib/autofix.js +432 -0
- package/lib/bridge/method_bridge.py +430 -0
- package/lib/card-source-resolution.mjs +284 -0
- package/lib/cards/cache.js +169 -0
- package/lib/cards/env.js +82 -0
- package/lib/cards/fetch-card-child.js +38 -0
- package/lib/cards/reader.js +435 -0
- package/lib/cards/refresh.js +111 -0
- package/lib/cards/remote.js +387 -0
- package/lib/cldf-export.mjs +540 -0
- package/lib/cldf-terms.mjs +62 -0
- package/lib/command-help.js +790 -0
- package/lib/commands/audit.js +49 -0
- package/lib/commands/card.js +454 -0
- package/lib/commands/doctor.js +559 -0
- package/lib/commands/fonts.js +489 -0
- package/lib/commands/help.js +91 -0
- package/lib/commands/init.js +1259 -0
- package/lib/commands/integrity.js +148 -0
- package/lib/commands/leaderboard.js +478 -0
- package/lib/commands/lint.js +30 -0
- package/lib/commands/models.js +177 -0
- package/lib/commands/plugin.js +103 -0
- package/lib/commands/provenance.js +45 -0
- package/lib/commands/recommend.js +75 -0
- package/lib/commands/register-corpus.js +678 -0
- package/lib/commands/repair-script.js +42 -0
- package/lib/commands/seal-corpus.js +355 -0
- package/lib/commands/seo.js +72 -0
- package/lib/commands/serve.js +147 -0
- package/lib/commands/status.js +265 -0
- package/lib/commands/submit.js +332 -0
- package/lib/commands/sync.js +89 -0
- package/lib/commands/tm.js +573 -0
- package/lib/commands/verify.js +39 -0
- package/lib/commands/watch.js +20 -0
- package/lib/commands/wrap.js +138 -0
- package/lib/commands/xliff.js +327 -0
- package/lib/commercial-eligibility.js +235 -0
- package/lib/concurrent.js +87 -0
- package/lib/config.js +523 -0
- package/lib/contamination-lane.js +76 -0
- package/lib/content-sync.js +731 -0
- package/lib/content.js +733 -0
- package/lib/corpus-registration.mjs +608 -0
- package/lib/cost-report.js +346 -0
- package/lib/diff.js +155 -0
- package/lib/docusaurus-sync.js +1256 -0
- package/lib/flatten.js +55 -0
- package/lib/format.js +954 -0
- package/lib/hash.js +159 -0
- package/lib/icu.js +473 -0
- package/lib/integrity.js +689 -0
- package/lib/license-gate.mjs +478 -0
- package/lib/license-identify.mjs +229 -0
- package/lib/lint.js +629 -0
- package/lib/method-manifest.js +60 -0
- package/lib/methods/anthropic.js +140 -0
- package/lib/methods/apertium.js +163 -0
- package/lib/methods/api.js +316 -0
- package/lib/methods/base.js +184 -0
- package/lib/methods/content-separator.js +45 -0
- package/lib/methods/deepl.js +426 -0
- package/lib/methods/direct-llm.js +586 -0
- package/lib/methods/external.js +332 -0
- package/lib/methods/fetch-with-retry.js +124 -0
- package/lib/methods/gemini.js +147 -0
- package/lib/methods/google-translate.js +402 -0
- package/lib/methods/http-utils.js +122 -0
- package/lib/methods/libretranslate.js +314 -0
- package/lib/methods/llm-coached.js +670 -0
- package/lib/methods/llm.js +592 -0
- package/lib/methods/local.js +76 -0
- package/lib/methods/microsoft-translator.js +331 -0
- package/lib/methods/openai.js +131 -0
- package/lib/methods/openrouter-client.js +327 -0
- package/lib/methods/openrouter-pricing.js +156 -0
- package/lib/methods/provider-env.js +115 -0
- package/lib/methods/provider-pricing.js +310 -0
- package/lib/methods/tilde.js +150 -0
- package/lib/methods/translated.js +229 -0
- package/lib/methods/translation-error.js +80 -0
- package/lib/models.js +258 -0
- package/lib/no-translate.js +233 -0
- package/lib/output.js +238 -0
- package/lib/pairs.js +547 -0
- package/lib/plugins.js +447 -0
- package/lib/provenance.js +323 -0
- package/lib/recommend.js +648 -0
- package/lib/registers.js +1185 -0
- package/lib/repair-script.js +266 -0
- package/lib/scripts.js +994 -0
- package/lib/seal.mjs +464 -0
- package/lib/sealed-qualifier.mjs +211 -0
- package/lib/security.js +59 -0
- package/lib/segment.js +369 -0
- package/lib/seo.js +275 -0
- package/lib/serve.js +854 -0
- package/lib/string-classify.js +85 -0
- package/lib/submit.mjs +344 -0
- package/lib/sync.js +969 -0
- package/lib/tags/bcp47.js +202 -0
- package/lib/tags/resolve.js +314 -0
- package/lib/terminology.js +111 -0
- package/lib/tm-seed.js +294 -0
- package/lib/tm.js +515 -0
- package/lib/translate-pair.js +197 -0
- package/lib/translate.js +203 -0
- package/lib/types.js +230 -0
- package/lib/validate.js +510 -0
- package/lib/verify.js +451 -0
- package/lib/watch.js +145 -0
- package/lib/xliff.js +184 -0
- package/package.json +93 -0
- package/shared/ATTRIBUTION.md +145 -0
- package/shared/CORPORA-CARDS.md +288 -0
- package/shared/DATA-SOVEREIGNTY.md +500 -0
- package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
- package/shared/card-lint-baseline.json +3189 -0
- package/shared/cards-fallback.json +1 -0
- package/shared/catalogue/card-config.json +6091 -0
- package/shared/catalogue/external-results.json +3888 -0
- package/shared/catalogue/gender-guidance.json +1038 -0
- package/shared/catalogue/method-coverage.json +1751 -0
- package/shared/catalogue/metric-coverage.json +170 -0
- package/shared/catalogue/metric-reliability.json +1 -0
- package/shared/catalogue/register-presets.json +3180 -0
- package/shared/catalogue/vitality-scales.json +55 -0
- package/shared/cldr-index.json +1115 -0
- package/shared/code-bridge.json +253 -0
- package/shared/corpora-cards-v1-reference.md +281 -0
- package/shared/curated-dictionary-flags.json +35 -0
- package/shared/curated-endonyms.json +35 -0
- package/shared/curated-fsts.json +51 -0
- package/shared/curated-orthography-conventions.json +26 -0
- package/shared/curated-sil-resources.json +374 -0
- package/shared/curated-tools.json +41 -0
- package/shared/docent/corpus.json +11333 -0
- package/shared/docent/faq.en.json +564 -0
- package/shared/docent/register-blocks.json +60 -0
- package/shared/docent/system-prompt.md +144 -0
- package/shared/domain-taxonomy.json +35 -0
- package/shared/explainers/glossary.json +2975 -0
- package/shared/explainers/tc-features.json +20112 -0
- package/shared/explainers/term-watchlist.json +147 -0
- package/shared/human-services.json +59 -0
- package/shared/license-corrections.json +261 -0
- package/shared/license-evidence.json +13452 -0
- package/shared/licenses.json +6781 -0
- package/shared/method-registry.json +236 -0
- package/shared/metric-registry.json +620 -0
- package/shared/model-aliases.json +7 -0
- package/shared/schemas/champollion-plugin.schema.json +206 -0
- package/shared/schemas/corpora-card.schema.json +957 -0
- package/shared/schemas/domain-taxonomy.schema.json +64 -0
- package/shared/schemas/external-results.schema.json +314 -0
- package/shared/schemas/human-services.schema.json +90 -0
- package/shared/schemas/language-card.schema.json +1308 -0
- package/shared/schemas/licenses.schema.json +155 -0
- package/shared/schemas/method-card.schema.json +412 -0
- package/shared/schemas/method-registry.schema.json +85 -0
- package/shared/schemas/metric-registry.schema.json +96 -0
- package/shared/schemas/metric-reliability.schema.json +178 -0
- package/shared/schemas/model-aliases.schema.json +27 -0
- package/shared/schemas/source-snapshot.schema.json +96 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External Method — bridge to Python TranslationMethod modules.
|
|
3
|
+
*
|
|
4
|
+
* This is the core Champollion deployment pipeline for custom methods:
|
|
5
|
+
*
|
|
6
|
+
* 1. pip install crk-translate (or quechua-translate, or any module)
|
|
7
|
+
* 2. champollion init → wizard detects the module, offers it
|
|
8
|
+
* 3. champollion sync → bridge spawns, translates, done
|
|
9
|
+
*
|
|
10
|
+
* The same module works in the Arena for evaluation:
|
|
11
|
+
* mt-eval run --method ./my-method/method_plugin
|
|
12
|
+
*
|
|
13
|
+
* HOW IT WORKS:
|
|
14
|
+
* The CLI spawns a Python subprocess running method_bridge.py (bundled
|
|
15
|
+
* in this npm package at lib/bridge/method_bridge.py). The bridge loads
|
|
16
|
+
* the method plugin, converts between CLI key-value format and Arena
|
|
17
|
+
* entry format, and communicates via JSON-lines on stdin/stdout.
|
|
18
|
+
*
|
|
19
|
+
* WHY SUBPROCESS (not HTTP, not FFI):
|
|
20
|
+
* - Zero config: no ports, no servers, no firewall issues in CI
|
|
21
|
+
* - Clean isolation: if the Python side crashes, it crashes with a
|
|
22
|
+
* traceback the user can read
|
|
23
|
+
* - Same pattern used by LSP, tree-sitter, and other tool bridges
|
|
24
|
+
*
|
|
25
|
+
* CONFIG:
|
|
26
|
+
* {
|
|
27
|
+
* "method": "external",
|
|
28
|
+
* "methodPath": "./my-method/method_plugin",
|
|
29
|
+
* "model": "anthropic/claude-sonnet-4.6" // passed through to module
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* MODEL PASSTHROUGH:
|
|
33
|
+
* The CLI config's "model" field flows through the bridge to the
|
|
34
|
+
* method plugin. CrkPipelineMethod reads config.model_id to decide
|
|
35
|
+
* which LLM to use for its translation steps. Users pick their model
|
|
36
|
+
* in champollion.config.json — the method module honors it.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { spawn } from 'node:child_process';
|
|
40
|
+
import { createInterface } from 'node:readline';
|
|
41
|
+
import { resolve, dirname } from 'node:path';
|
|
42
|
+
import { fileURLToPath } from 'node:url';
|
|
43
|
+
import { TranslationMethod } from './base.js';
|
|
44
|
+
import { output } from '../output.js';
|
|
45
|
+
|
|
46
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Path to the bundled bridge script. Ships with the CLI npm package
|
|
50
|
+
* in lib/bridge/method_bridge.py. No Arena dependency needed.
|
|
51
|
+
*/
|
|
52
|
+
const BRIDGE_SCRIPT = resolve(__dirname, '..', 'bridge', 'method_bridge.py');
|
|
53
|
+
|
|
54
|
+
class ExternalMethod extends TranslationMethod {
|
|
55
|
+
constructor(options = {}) {
|
|
56
|
+
super('external', options);
|
|
57
|
+
this._methodPath = options.methodPath || null;
|
|
58
|
+
this._process = null;
|
|
59
|
+
this._rl = null;
|
|
60
|
+
this._methodName = null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Start the bridge subprocess and wait for the ready signal.
|
|
65
|
+
*
|
|
66
|
+
* The bridge sends a JSON ready signal on stdout immediately after
|
|
67
|
+
* loading the method plugin. If loading fails, it sends an error
|
|
68
|
+
* object and exits.
|
|
69
|
+
*/
|
|
70
|
+
async _ensureBridge(cwd) {
|
|
71
|
+
if (this._process) return;
|
|
72
|
+
|
|
73
|
+
const methodPath = this._methodPath
|
|
74
|
+
? resolve(cwd || '.', this._methodPath)
|
|
75
|
+
: null;
|
|
76
|
+
|
|
77
|
+
const args = [BRIDGE_SCRIPT];
|
|
78
|
+
if (methodPath) args.push('--method', methodPath);
|
|
79
|
+
|
|
80
|
+
// Try python3 first (macOS/Linux convention), then python (Windows)
|
|
81
|
+
let pythonCmd = 'python3';
|
|
82
|
+
try {
|
|
83
|
+
this._process = spawn(pythonCmd, args, {
|
|
84
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
85
|
+
cwd: cwd || process.cwd(),
|
|
86
|
+
});
|
|
87
|
+
} catch {
|
|
88
|
+
pythonCmd = 'python';
|
|
89
|
+
this._process = spawn(pythonCmd, args, {
|
|
90
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
91
|
+
cwd: cwd || process.cwd(),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Handle spawn failure (Python not installed)
|
|
96
|
+
this._process.on('error', (err) => {
|
|
97
|
+
if (err.code === 'ENOENT') {
|
|
98
|
+
throw new Error(
|
|
99
|
+
'Python is required for external method modules but was not found.\n' +
|
|
100
|
+
' Install Python 3.10+ from https://python.org'
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
throw err;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Python logging → CLI info channel (stderr is for logs, not errors)
|
|
107
|
+
const stderrRl = createInterface({ input: this._process.stderr });
|
|
108
|
+
stderrRl.on('line', (line) => output.info(` [method] ${line}`));
|
|
109
|
+
|
|
110
|
+
// stdout → JSON-lines protocol
|
|
111
|
+
this._rl = createInterface({ input: this._process.stdout });
|
|
112
|
+
|
|
113
|
+
// Wait for the ready signal (first line of output)
|
|
114
|
+
const ready = await this._readResponse();
|
|
115
|
+
if (!ready.ok) {
|
|
116
|
+
throw new Error(ready.error);
|
|
117
|
+
}
|
|
118
|
+
this._methodName = ready.name || 'External Method';
|
|
119
|
+
output.info(` ✓ Method bridge: ${this._methodName}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Send a JSON request line and read the response line.
|
|
124
|
+
*/
|
|
125
|
+
async _request(obj) {
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const line = JSON.stringify(obj) + '\n';
|
|
128
|
+
this._process.stdin.write(line, 'utf-8', (err) => {
|
|
129
|
+
if (err) return reject(err);
|
|
130
|
+
this._readResponse().then(resolve).catch(reject);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Read one JSON-lines response from the bridge subprocess.
|
|
137
|
+
*/
|
|
138
|
+
_readResponse() {
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
const onLine = (line) => {
|
|
141
|
+
try {
|
|
142
|
+
resolve(JSON.parse(line));
|
|
143
|
+
} catch (e) {
|
|
144
|
+
reject(new Error(`Invalid response from method bridge: ${line}`));
|
|
145
|
+
}
|
|
146
|
+
this._rl.removeListener('line', onLine);
|
|
147
|
+
this._process.removeListener('exit', onExit);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const onExit = (code) => {
|
|
151
|
+
this._rl.removeListener('line', onLine);
|
|
152
|
+
reject(new Error(
|
|
153
|
+
`Method bridge exited unexpectedly (code ${code}).\n` +
|
|
154
|
+
' Check that the method module is installed correctly.\n' +
|
|
155
|
+
' Verify the method module is pip-installed (e.g., pip install <package-name>).'
|
|
156
|
+
));
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
this._rl.on('line', onLine);
|
|
160
|
+
this._process.once('exit', onExit);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Discover installed method modules.
|
|
166
|
+
*
|
|
167
|
+
* Spawns the bridge in --discover mode, which scans for pip-installed
|
|
168
|
+
* packages that register champollion.methods entry points.
|
|
169
|
+
*
|
|
170
|
+
* Used by the init wizard and `champollion methods` command.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} [cwd] - Working directory
|
|
173
|
+
* @returns {Promise<Array<{name, method_id, supported_pairs, entry_point, plugin_path}>>}
|
|
174
|
+
*/
|
|
175
|
+
static async discoverMethods(cwd) {
|
|
176
|
+
return new Promise((resolve) => {
|
|
177
|
+
let proc;
|
|
178
|
+
try {
|
|
179
|
+
proc = spawn('python3', [BRIDGE_SCRIPT, '--discover'], {
|
|
180
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
181
|
+
cwd: cwd || process.cwd(),
|
|
182
|
+
});
|
|
183
|
+
} catch {
|
|
184
|
+
try {
|
|
185
|
+
proc = spawn('python', [BRIDGE_SCRIPT, '--discover'], {
|
|
186
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
187
|
+
cwd: cwd || process.cwd(),
|
|
188
|
+
});
|
|
189
|
+
} catch {
|
|
190
|
+
// Python not available — no method modules discoverable
|
|
191
|
+
resolve([]);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let data = '';
|
|
197
|
+
proc.stdout.on('data', (chunk) => { data += chunk; });
|
|
198
|
+
|
|
199
|
+
proc.on('close', () => {
|
|
200
|
+
try {
|
|
201
|
+
const result = JSON.parse(data.trim());
|
|
202
|
+
resolve(result.ok ? result.methods : []);
|
|
203
|
+
} catch {
|
|
204
|
+
resolve([]);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
proc.on('error', () => resolve([]));
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Preflight check — can we spawn the bridge and load the method?
|
|
214
|
+
*
|
|
215
|
+
* Called by resolveRuntime() BEFORE entering the translation loop.
|
|
216
|
+
* Human-readable error messages tell the user exactly what to do.
|
|
217
|
+
*/
|
|
218
|
+
async checkReadiness(context) {
|
|
219
|
+
if (!this._methodPath) {
|
|
220
|
+
return {
|
|
221
|
+
ready: false,
|
|
222
|
+
reason:
|
|
223
|
+
'No method module configured.\n' +
|
|
224
|
+
' Set "methodPath" in your champollion.config.json,\n' +
|
|
225
|
+
' or run "champollion init" to set up a method module.',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await this._ensureBridge(context.cwd);
|
|
231
|
+
return { ready: true };
|
|
232
|
+
} catch (err) {
|
|
233
|
+
return { ready: false, reason: err.message };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Translate key-value pairs via the bridge.
|
|
239
|
+
*
|
|
240
|
+
* The bridge converts CLI format to Arena format, calls the method's
|
|
241
|
+
* translate(), and converts results back. Model passthrough:
|
|
242
|
+
* pairConfig.model flows through to the method plugin.
|
|
243
|
+
*/
|
|
244
|
+
async translate(keys, sourceFlat, pairConfig, options) {
|
|
245
|
+
await this._ensureBridge(options.cwd || pairConfig.cwd);
|
|
246
|
+
|
|
247
|
+
// Build key → source value map
|
|
248
|
+
const keysPayload = {};
|
|
249
|
+
for (const key of keys) {
|
|
250
|
+
const value = sourceFlat[key];
|
|
251
|
+
if (value && typeof value === 'string') {
|
|
252
|
+
keysPayload[key] = value;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (Object.keys(keysPayload).length === 0) return {};
|
|
257
|
+
|
|
258
|
+
const response = await this._request({
|
|
259
|
+
action: 'translate',
|
|
260
|
+
keys: keysPayload,
|
|
261
|
+
source_locale: pairConfig.source || 'en',
|
|
262
|
+
target_locale: pairConfig.target,
|
|
263
|
+
config: {
|
|
264
|
+
model: pairConfig.model || options.model,
|
|
265
|
+
temperature: pairConfig.temperature,
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
if (!response.ok) {
|
|
270
|
+
output.error(` Method error: ${response.error}`);
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Surface per-key errors as warnings (partial success is valid)
|
|
275
|
+
if (response.meta?.errors?.length > 0) {
|
|
276
|
+
for (const err of response.meta.errors) {
|
|
277
|
+
output.warn(` [method] ${err.key}: ${err.error}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return response.translations || null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Content translation — not supported by external methods yet.
|
|
286
|
+
*
|
|
287
|
+
* External methods handle key-value i18n translation (the core use case).
|
|
288
|
+
* Freeform Markdown content translation would need a separate protocol
|
|
289
|
+
* action and is deferred to a future version.
|
|
290
|
+
*/
|
|
291
|
+
async translateContent(_prompt, _pairConfig, _options) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Cost estimation — determined by the method module, not the CLI.
|
|
297
|
+
*/
|
|
298
|
+
estimateCost(keyCount) {
|
|
299
|
+
return {
|
|
300
|
+
estimatedCost: null,
|
|
301
|
+
currency: 'USD',
|
|
302
|
+
source: 'method-determined',
|
|
303
|
+
note: 'Cost is determined by the method module.',
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
getSetupHelp() {
|
|
308
|
+
return [
|
|
309
|
+
' External method module.',
|
|
310
|
+
' 1. Install a method module (e.g., pip install <module-name>)',
|
|
311
|
+
' 2. Run: champollion init',
|
|
312
|
+
' 3. The wizard will detect and configure it automatically.',
|
|
313
|
+
];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Clean up: send shutdown command and kill the subprocess.
|
|
318
|
+
*/
|
|
319
|
+
async shutdown() {
|
|
320
|
+
if (this._process) {
|
|
321
|
+
try {
|
|
322
|
+
await this._request({ action: 'shutdown' });
|
|
323
|
+
} catch {
|
|
324
|
+
// Process may already be dead — that's fine
|
|
325
|
+
}
|
|
326
|
+
this._process.kill();
|
|
327
|
+
this._process = null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export { ExternalMethod };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetchWithRetry — shared HTTP fetch with timeout, retry, and backoff.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS:
|
|
5
|
+
* Every non-LLM method adapter (Google Translate, DeepL, Microsoft,
|
|
6
|
+
* LibreTranslate) was independently implementing the same ~50-line retry
|
|
7
|
+
* loop: AbortController + timeout, isRetryable() check, exponential
|
|
8
|
+
* backoff with jitter, and error logging. When a bug was fixed in one
|
|
9
|
+
* adapter's retry logic, the others wouldn't get the fix.
|
|
10
|
+
*
|
|
11
|
+
* This module extracts that shared pattern into a single function so
|
|
12
|
+
* retry behavior is consistent and bug fixes propagate automatically.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ MAINTENANCE NOTE FOR FUTURE DEVELOPERS:
|
|
15
|
+
* If you're adding a new translation method that uses a REST API,
|
|
16
|
+
* use this function instead of writing your own retry loop.
|
|
17
|
+
* If you need custom retry behavior (e.g., the LLM cascade in
|
|
18
|
+
* direct-llm.js), extend this or document why your case is different.
|
|
19
|
+
*
|
|
20
|
+
* WHAT THIS HANDLES:
|
|
21
|
+
* - AbortController + configurable per-request timeout
|
|
22
|
+
* - Retryable status detection (429, 5xx) via isRetryable()
|
|
23
|
+
* - Exponential backoff with jitter via getBackoffDelay()
|
|
24
|
+
* - Timeout (AbortError) retries
|
|
25
|
+
* - Consistent error logging format
|
|
26
|
+
*
|
|
27
|
+
* WHAT THIS DOES NOT HANDLE (method-specific):
|
|
28
|
+
* - Response body parsing (JSON structure varies per provider)
|
|
29
|
+
* - Key mapping (ordered arrays → key-value maps)
|
|
30
|
+
* - Glossary creation, formality parameters, locale normalization
|
|
31
|
+
* - Prompt building
|
|
32
|
+
*
|
|
33
|
+
* RETURN CONTRACT:
|
|
34
|
+
* - Returns the raw Response object for ANY non-retryable status (2xx, 400, 401, etc.)
|
|
35
|
+
* - Returns null ONLY when all retries are exhausted on retryable errors (429, 5xx)
|
|
36
|
+
* - This lets callers handle method-specific error cases (e.g., DeepL's glossary-400)
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
MAX_RETRIES,
|
|
41
|
+
isRetryable,
|
|
42
|
+
getBackoffDelay,
|
|
43
|
+
sleep,
|
|
44
|
+
} from './http-utils.js';
|
|
45
|
+
import { output } from '../output.js';
|
|
46
|
+
import { recordTranslationError } from './translation-error.js';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Fetch a URL with automatic retry, timeout, and exponential backoff.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} url - Endpoint URL
|
|
52
|
+
* @param {object} fetchOptions - Standard fetch() options (method, headers, body).
|
|
53
|
+
* Do NOT include a 'signal' property — one will be created from the timeout.
|
|
54
|
+
* @param {object} [retryOptions={}]
|
|
55
|
+
* @param {string} retryOptions.label - Log label for error messages (e.g., 'DeepL batch 3')
|
|
56
|
+
* @param {number} [retryOptions.timeoutMs=30000] - Per-request timeout in milliseconds
|
|
57
|
+
* @param {number} [retryOptions.maxRetries] - Max retry attempts (defaults to MAX_RETRIES from http-utils)
|
|
58
|
+
* @param {number} [retryOptions.startAttempt=0] - Starting attempt number.
|
|
59
|
+
* Used to share retry budgets across recursive calls (e.g., DeepL glossary fallback).
|
|
60
|
+
* @returns {Promise<Response|null>} Fetch Response on any non-retryable status, null on retry exhaustion
|
|
61
|
+
*/
|
|
62
|
+
async function fetchWithRetry(url, fetchOptions, retryOptions = {}) {
|
|
63
|
+
const {
|
|
64
|
+
label = 'HTTP',
|
|
65
|
+
timeoutMs = 30000,
|
|
66
|
+
maxRetries = MAX_RETRIES,
|
|
67
|
+
startAttempt = 0,
|
|
68
|
+
} = retryOptions;
|
|
69
|
+
|
|
70
|
+
for (let attempt = startAttempt; attempt <= maxRetries; attempt++) {
|
|
71
|
+
try {
|
|
72
|
+
// Each attempt gets a fresh AbortController so the timeout resets.
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
75
|
+
|
|
76
|
+
const response = await fetch(url, {
|
|
77
|
+
...fetchOptions,
|
|
78
|
+
signal: controller.signal,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
clearTimeout(timeoutId);
|
|
82
|
+
|
|
83
|
+
// Retryable status (429 Too Many Requests, 5xx Server Error):
|
|
84
|
+
// back off and try again, unless we've exhausted all attempts.
|
|
85
|
+
if (isRetryable(response.status)) {
|
|
86
|
+
if (attempt < maxRetries) {
|
|
87
|
+
const delay = getBackoffDelay(attempt);
|
|
88
|
+
output.warn(`⏳ ${label}: ${response.status} — retrying in ${Math.round(delay / 1000)}s...`);
|
|
89
|
+
await sleep(delay);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
output.error(`${label}: ${response.status} after ${maxRetries + 1} attempts`);
|
|
93
|
+
recordTranslationError(response.status);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Any non-retryable response (2xx, 400, 401, 403, 404, etc.):
|
|
98
|
+
// return to caller for method-specific handling. Record non-2xx
|
|
99
|
+
// statuses centrally so every fetchWithRetry-based provider's
|
|
100
|
+
// getSetupHelp() can tell an auth failure (401/403) from quota.
|
|
101
|
+
if (!response.ok) recordTranslationError(response.status);
|
|
102
|
+
return response;
|
|
103
|
+
|
|
104
|
+
} catch (err) {
|
|
105
|
+
// AbortError = our timeout fired. Other errors = network failure, DNS, etc.
|
|
106
|
+
const isTimeout = err.name === 'AbortError';
|
|
107
|
+
const errLabel = isTimeout ? 'Timeout' : err.message;
|
|
108
|
+
|
|
109
|
+
if (attempt < maxRetries) {
|
|
110
|
+
const delay = getBackoffDelay(attempt);
|
|
111
|
+
output.warn(`⏳ ${label}: ${errLabel} — retrying in ${Math.round(delay / 1000)}s...`);
|
|
112
|
+
await sleep(delay);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
output.error(`${label}: ${errLabel} after ${maxRetries + 1} attempts`);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export { fetchWithRetry };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini Translation Method — direct Google Gemini API.
|
|
3
|
+
*
|
|
4
|
+
* Extends DirectLLMMethod to provide Gemini-specific:
|
|
5
|
+
* - API endpoint (generativelanguage.googleapis.com, key in query string)
|
|
6
|
+
* - Request body (contents/parts, systemInstruction, responseMimeType)
|
|
7
|
+
* - Response parsing (candidates[0].content.parts[0].text)
|
|
8
|
+
* - Model listing via GET /v1beta/models
|
|
9
|
+
* - Pricing (gemini-2.5-flash, gemini-2.5-pro)
|
|
10
|
+
*
|
|
11
|
+
* All shared logic (translate, translateContent, coaching, retry, validation)
|
|
12
|
+
* lives in DirectLLMMethod.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { DirectLLMMethod } from './direct-llm.js';
|
|
16
|
+
import { fetchAvailableModels } from '../models.js';
|
|
17
|
+
import { estimateLlmCost } from './provider-pricing.js';
|
|
18
|
+
|
|
19
|
+
// The default model, named ONCE — it was previously written twice (here and
|
|
20
|
+
// as an inline fallback in estimateCost()) and the two could drift.
|
|
21
|
+
const DEFAULT_MODEL = 'gemini-2.5-flash';
|
|
22
|
+
|
|
23
|
+
class GeminiMethod extends DirectLLMMethod {
|
|
24
|
+
constructor(options = {}) {
|
|
25
|
+
super(options);
|
|
26
|
+
this.name = 'gemini';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Provider identity ────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
_getApiKeyEnvVar() { return 'GEMINI_API_KEY'; }
|
|
32
|
+
_getApiKeyOptionsKey() { return 'geminiApiKey'; }
|
|
33
|
+
_getDefaultModel() { return DEFAULT_MODEL; }
|
|
34
|
+
_getProviderLabel() { return 'Gemini'; }
|
|
35
|
+
|
|
36
|
+
// ── API request/response shape ───────────────────────────────────
|
|
37
|
+
|
|
38
|
+
_buildApiRequest({ prompt, systemMessage, apiKey, model, temperature, isJsonMode }) {
|
|
39
|
+
// Gemini puts the API key in the query string (not a header).
|
|
40
|
+
// System messages use the separate systemInstruction field.
|
|
41
|
+
const body = {
|
|
42
|
+
contents: [
|
|
43
|
+
{
|
|
44
|
+
role: 'user',
|
|
45
|
+
parts: [{ text: prompt }],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
generationConfig: {
|
|
49
|
+
temperature,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (systemMessage) {
|
|
54
|
+
body.systemInstruction = {
|
|
55
|
+
parts: [{ text: systemMessage }],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (isJsonMode) {
|
|
60
|
+
body.generationConfig.responseMimeType = 'application/json';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
url: `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`,
|
|
65
|
+
headers: {
|
|
66
|
+
'Content-Type': 'application/json',
|
|
67
|
+
},
|
|
68
|
+
body,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_extractResponseText(json) {
|
|
73
|
+
return json.candidates?.[0]?.content?.parts?.[0]?.text || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Runtime model listing ────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
async _fetchModels(apiKey) {
|
|
79
|
+
// Delegate to the shared models.js module — single source of truth for
|
|
80
|
+
// model listing used by init wizard, `champollion models`, and validation.
|
|
81
|
+
return fetchAvailableModels('gemini', apiKey);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Model-aware quality tier ─────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
_getModelTier(model) {
|
|
87
|
+
if (model.includes('pro')) return 'premium';
|
|
88
|
+
return 'standard'; // flash
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Pricing ──────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
async estimateCost(keyCount, pairConfig = {}) {
|
|
94
|
+
const estimate = await estimateLlmCost('gemini', pairConfig.model || DEFAULT_MODEL, keyCount);
|
|
95
|
+
// Gemini's free tier can make the real charge lower than the estimate;
|
|
96
|
+
// say so rather than presenting the priced figure as what they'll pay.
|
|
97
|
+
if (estimate.estimatedCost !== null) {
|
|
98
|
+
estimate.note += ' Free tier may apply.';
|
|
99
|
+
}
|
|
100
|
+
return estimate;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Provenance ───────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
checkReadiness(context) {
|
|
106
|
+
// Resolve through the same chain translate() uses (options → env →
|
|
107
|
+
// .env.local/.env in cwd) so readiness can never fail for a key the
|
|
108
|
+
// loader *would* read — e.g. GEMINI_API_KEY set only in .env.local.
|
|
109
|
+
if (!this._resolveApiKey(context || {})) {
|
|
110
|
+
return { ready: false, reason: 'No Gemini API key (GEMINI_API_KEY).' };
|
|
111
|
+
}
|
|
112
|
+
return { ready: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getProvenance() {
|
|
116
|
+
return {
|
|
117
|
+
resources: [
|
|
118
|
+
{
|
|
119
|
+
name: 'Google Gemini API',
|
|
120
|
+
license: 'Proprietary (Google ToS)',
|
|
121
|
+
type: 'api',
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
commercialReady: true,
|
|
125
|
+
flags: [],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
getSetupHelp() {
|
|
130
|
+
const apiKey = process.env.GEMINI_API_KEY;
|
|
131
|
+
if (!apiKey) {
|
|
132
|
+
return [
|
|
133
|
+
'',
|
|
134
|
+
' ┌─ Missing API Key ─────────────────────────────────────────────┐',
|
|
135
|
+
' │ The Gemini method requires a Google AI API key. │',
|
|
136
|
+
' │ │',
|
|
137
|
+
' │ 1. Get a key at https://aistudio.google.com/apikey │',
|
|
138
|
+
' │ 2. Run: export GEMINI_API_KEY=... │',
|
|
139
|
+
' │ 3. Or add to .env.local: GEMINI_API_KEY=... │',
|
|
140
|
+
' └────────────────────────────────────────────────────────────────┘',
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
return this._apiFailureHelp('Google AI Studio');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export { GeminiMethod };
|