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.
Files changed (170) hide show
  1. package/LICENSE +133 -0
  2. package/README.md +387 -0
  3. package/bin/cli.js +278 -0
  4. package/index.js +135 -0
  5. package/lib/api-key.js +127 -0
  6. package/lib/autofix.js +432 -0
  7. package/lib/bridge/method_bridge.py +430 -0
  8. package/lib/card-source-resolution.mjs +284 -0
  9. package/lib/cards/cache.js +169 -0
  10. package/lib/cards/env.js +82 -0
  11. package/lib/cards/fetch-card-child.js +38 -0
  12. package/lib/cards/reader.js +435 -0
  13. package/lib/cards/refresh.js +111 -0
  14. package/lib/cards/remote.js +387 -0
  15. package/lib/cldf-export.mjs +540 -0
  16. package/lib/cldf-terms.mjs +62 -0
  17. package/lib/command-help.js +790 -0
  18. package/lib/commands/audit.js +49 -0
  19. package/lib/commands/card.js +454 -0
  20. package/lib/commands/doctor.js +559 -0
  21. package/lib/commands/fonts.js +489 -0
  22. package/lib/commands/help.js +91 -0
  23. package/lib/commands/init.js +1259 -0
  24. package/lib/commands/integrity.js +148 -0
  25. package/lib/commands/leaderboard.js +478 -0
  26. package/lib/commands/lint.js +30 -0
  27. package/lib/commands/models.js +177 -0
  28. package/lib/commands/plugin.js +103 -0
  29. package/lib/commands/provenance.js +45 -0
  30. package/lib/commands/recommend.js +75 -0
  31. package/lib/commands/register-corpus.js +678 -0
  32. package/lib/commands/repair-script.js +42 -0
  33. package/lib/commands/seal-corpus.js +355 -0
  34. package/lib/commands/seo.js +72 -0
  35. package/lib/commands/serve.js +147 -0
  36. package/lib/commands/status.js +265 -0
  37. package/lib/commands/submit.js +332 -0
  38. package/lib/commands/sync.js +89 -0
  39. package/lib/commands/tm.js +573 -0
  40. package/lib/commands/verify.js +39 -0
  41. package/lib/commands/watch.js +20 -0
  42. package/lib/commands/wrap.js +138 -0
  43. package/lib/commands/xliff.js +327 -0
  44. package/lib/commercial-eligibility.js +235 -0
  45. package/lib/concurrent.js +87 -0
  46. package/lib/config.js +523 -0
  47. package/lib/contamination-lane.js +76 -0
  48. package/lib/content-sync.js +731 -0
  49. package/lib/content.js +733 -0
  50. package/lib/corpus-registration.mjs +608 -0
  51. package/lib/cost-report.js +346 -0
  52. package/lib/diff.js +155 -0
  53. package/lib/docusaurus-sync.js +1256 -0
  54. package/lib/flatten.js +55 -0
  55. package/lib/format.js +954 -0
  56. package/lib/hash.js +159 -0
  57. package/lib/icu.js +473 -0
  58. package/lib/integrity.js +689 -0
  59. package/lib/license-gate.mjs +478 -0
  60. package/lib/license-identify.mjs +229 -0
  61. package/lib/lint.js +629 -0
  62. package/lib/method-manifest.js +60 -0
  63. package/lib/methods/anthropic.js +140 -0
  64. package/lib/methods/apertium.js +163 -0
  65. package/lib/methods/api.js +316 -0
  66. package/lib/methods/base.js +184 -0
  67. package/lib/methods/content-separator.js +45 -0
  68. package/lib/methods/deepl.js +426 -0
  69. package/lib/methods/direct-llm.js +586 -0
  70. package/lib/methods/external.js +332 -0
  71. package/lib/methods/fetch-with-retry.js +124 -0
  72. package/lib/methods/gemini.js +147 -0
  73. package/lib/methods/google-translate.js +402 -0
  74. package/lib/methods/http-utils.js +122 -0
  75. package/lib/methods/libretranslate.js +314 -0
  76. package/lib/methods/llm-coached.js +670 -0
  77. package/lib/methods/llm.js +592 -0
  78. package/lib/methods/local.js +76 -0
  79. package/lib/methods/microsoft-translator.js +331 -0
  80. package/lib/methods/openai.js +131 -0
  81. package/lib/methods/openrouter-client.js +327 -0
  82. package/lib/methods/openrouter-pricing.js +156 -0
  83. package/lib/methods/provider-env.js +115 -0
  84. package/lib/methods/provider-pricing.js +310 -0
  85. package/lib/methods/tilde.js +150 -0
  86. package/lib/methods/translated.js +229 -0
  87. package/lib/methods/translation-error.js +80 -0
  88. package/lib/models.js +258 -0
  89. package/lib/no-translate.js +233 -0
  90. package/lib/output.js +238 -0
  91. package/lib/pairs.js +547 -0
  92. package/lib/plugins.js +447 -0
  93. package/lib/provenance.js +323 -0
  94. package/lib/recommend.js +648 -0
  95. package/lib/registers.js +1185 -0
  96. package/lib/repair-script.js +266 -0
  97. package/lib/scripts.js +994 -0
  98. package/lib/seal.mjs +464 -0
  99. package/lib/sealed-qualifier.mjs +211 -0
  100. package/lib/security.js +59 -0
  101. package/lib/segment.js +369 -0
  102. package/lib/seo.js +275 -0
  103. package/lib/serve.js +854 -0
  104. package/lib/string-classify.js +85 -0
  105. package/lib/submit.mjs +344 -0
  106. package/lib/sync.js +969 -0
  107. package/lib/tags/bcp47.js +202 -0
  108. package/lib/tags/resolve.js +314 -0
  109. package/lib/terminology.js +111 -0
  110. package/lib/tm-seed.js +294 -0
  111. package/lib/tm.js +515 -0
  112. package/lib/translate-pair.js +197 -0
  113. package/lib/translate.js +203 -0
  114. package/lib/types.js +230 -0
  115. package/lib/validate.js +510 -0
  116. package/lib/verify.js +451 -0
  117. package/lib/watch.js +145 -0
  118. package/lib/xliff.js +184 -0
  119. package/package.json +93 -0
  120. package/shared/ATTRIBUTION.md +145 -0
  121. package/shared/CORPORA-CARDS.md +288 -0
  122. package/shared/DATA-SOVEREIGNTY.md +500 -0
  123. package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
  124. package/shared/card-lint-baseline.json +3189 -0
  125. package/shared/cards-fallback.json +1 -0
  126. package/shared/catalogue/card-config.json +6091 -0
  127. package/shared/catalogue/external-results.json +3888 -0
  128. package/shared/catalogue/gender-guidance.json +1038 -0
  129. package/shared/catalogue/method-coverage.json +1751 -0
  130. package/shared/catalogue/metric-coverage.json +170 -0
  131. package/shared/catalogue/metric-reliability.json +1 -0
  132. package/shared/catalogue/register-presets.json +3180 -0
  133. package/shared/catalogue/vitality-scales.json +55 -0
  134. package/shared/cldr-index.json +1115 -0
  135. package/shared/code-bridge.json +253 -0
  136. package/shared/corpora-cards-v1-reference.md +281 -0
  137. package/shared/curated-dictionary-flags.json +35 -0
  138. package/shared/curated-endonyms.json +35 -0
  139. package/shared/curated-fsts.json +51 -0
  140. package/shared/curated-orthography-conventions.json +26 -0
  141. package/shared/curated-sil-resources.json +374 -0
  142. package/shared/curated-tools.json +41 -0
  143. package/shared/docent/corpus.json +11333 -0
  144. package/shared/docent/faq.en.json +564 -0
  145. package/shared/docent/register-blocks.json +60 -0
  146. package/shared/docent/system-prompt.md +144 -0
  147. package/shared/domain-taxonomy.json +35 -0
  148. package/shared/explainers/glossary.json +2975 -0
  149. package/shared/explainers/tc-features.json +20112 -0
  150. package/shared/explainers/term-watchlist.json +147 -0
  151. package/shared/human-services.json +59 -0
  152. package/shared/license-corrections.json +261 -0
  153. package/shared/license-evidence.json +13452 -0
  154. package/shared/licenses.json +6781 -0
  155. package/shared/method-registry.json +236 -0
  156. package/shared/metric-registry.json +620 -0
  157. package/shared/model-aliases.json +7 -0
  158. package/shared/schemas/champollion-plugin.schema.json +206 -0
  159. package/shared/schemas/corpora-card.schema.json +957 -0
  160. package/shared/schemas/domain-taxonomy.schema.json +64 -0
  161. package/shared/schemas/external-results.schema.json +314 -0
  162. package/shared/schemas/human-services.schema.json +90 -0
  163. package/shared/schemas/language-card.schema.json +1308 -0
  164. package/shared/schemas/licenses.schema.json +155 -0
  165. package/shared/schemas/method-card.schema.json +412 -0
  166. package/shared/schemas/method-registry.schema.json +85 -0
  167. package/shared/schemas/metric-registry.schema.json +96 -0
  168. package/shared/schemas/metric-reliability.schema.json +178 -0
  169. package/shared/schemas/model-aliases.schema.json +27 -0
  170. package/shared/schemas/source-snapshot.schema.json +96 -0
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Anthropic Translation Method — direct Anthropic Messages API.
3
+ *
4
+ * Extends DirectLLMMethod to provide Anthropic-specific:
5
+ * - API endpoint (https://api.anthropic.com/v1/messages)
6
+ * - Auth header format (x-api-key + anthropic-version)
7
+ * - Request body (messages, system param, temperature)
8
+ * - Response parsing (content[0].text)
9
+ * - Model listing via GET /v1/models
10
+ * - Pricing (claude-sonnet, claude-haiku, claude-opus)
11
+ *
12
+ * All shared logic (translate, translateContent, coaching, retry, validation)
13
+ * lives in DirectLLMMethod.
14
+ */
15
+
16
+ import { DirectLLMMethod } from './direct-llm.js';
17
+ import { fetchAvailableModels } from '../models.js';
18
+ import { estimateLlmCost } from './provider-pricing.js';
19
+
20
+ // The default model, named ONCE. It was previously written twice — in
21
+ // _getDefaultModel() and again as an inline `pairConfig.model || '...'`
22
+ // fallback in estimateCost() — so the two could drift and price a run
23
+ // against a model it did not use.
24
+ const DEFAULT_MODEL = 'claude-sonnet-4-6';
25
+
26
+ class AnthropicMethod extends DirectLLMMethod {
27
+ constructor(options = {}) {
28
+ super(options);
29
+ this.name = 'anthropic';
30
+ }
31
+
32
+ // ── Provider identity ────────────────────────────────────────────
33
+
34
+ _getApiKeyEnvVar() { return 'ANTHROPIC_API_KEY'; }
35
+ _getApiKeyOptionsKey() { return 'anthropicApiKey'; }
36
+ _getDefaultModel() { return DEFAULT_MODEL; }
37
+ _getProviderLabel() { return 'Anthropic'; }
38
+
39
+ // ── API request/response shape ───────────────────────────────────
40
+
41
+ _buildApiRequest({ prompt, systemMessage, apiKey, model, temperature }) {
42
+ // Anthropic uses a separate 'system' field rather than a system message in
43
+ // the messages array. This is important for prompt caching — the system
44
+ // field is cached independently by Anthropic's infrastructure.
45
+ // Anthropic requires max_tokens as a mandatory parameter.
46
+ // Set generously high — the model stops naturally when done.
47
+ // A low value (e.g., 4096) risks truncating large translation batches.
48
+ const body = {
49
+ model,
50
+ messages: [{ role: 'user', content: prompt }],
51
+ max_tokens: 16384,
52
+ temperature,
53
+ };
54
+
55
+ if (systemMessage) {
56
+ body.system = systemMessage;
57
+ }
58
+
59
+ return {
60
+ url: 'https://api.anthropic.com/v1/messages',
61
+ headers: {
62
+ 'x-api-key': apiKey,
63
+ 'anthropic-version': '2023-06-01',
64
+ 'Content-Type': 'application/json',
65
+ },
66
+ body,
67
+ };
68
+ }
69
+
70
+ _extractResponseText(json) {
71
+ return json.content?.[0]?.text || null;
72
+ }
73
+
74
+ // ── Runtime model listing ────────────────────────────────────────
75
+
76
+ async _fetchModels(apiKey) {
77
+ // Delegate to the shared models.js module — single source of truth for
78
+ // model listing used by init wizard, `champollion models`, and validation.
79
+ return fetchAvailableModels('anthropic', apiKey);
80
+ }
81
+
82
+ // ── Model-aware quality tier ─────────────────────────────────────
83
+
84
+ _getModelTier(model) {
85
+ if (model.includes('haiku')) return 'budget';
86
+ if (model.includes('opus')) return 'premium';
87
+ return 'standard'; // sonnet
88
+ }
89
+
90
+ // ── Pricing ──────────────────────────────────────────────────────
91
+
92
+ async estimateCost(keyCount, pairConfig = {}) {
93
+ return estimateLlmCost('anthropic', pairConfig.model || DEFAULT_MODEL, keyCount);
94
+ }
95
+
96
+ // ── Provenance ───────────────────────────────────────────────────
97
+
98
+ checkReadiness(context) {
99
+ // Resolve through the same chain translate() uses (options → env →
100
+ // .env.local/.env in cwd) so readiness can never fail for a key the
101
+ // loader *would* read — e.g. ANTHROPIC_API_KEY set only in .env.local.
102
+ if (!this._resolveApiKey(context || {})) {
103
+ return { ready: false, reason: 'No Anthropic API key (ANTHROPIC_API_KEY).' };
104
+ }
105
+ return { ready: true };
106
+ }
107
+
108
+ getProvenance() {
109
+ return {
110
+ resources: [
111
+ {
112
+ name: 'Anthropic Messages API',
113
+ license: 'Proprietary (Anthropic ToS)',
114
+ type: 'api',
115
+ },
116
+ ],
117
+ commercialReady: true,
118
+ flags: [],
119
+ };
120
+ }
121
+
122
+ getSetupHelp() {
123
+ const apiKey = process.env.ANTHROPIC_API_KEY;
124
+ if (!apiKey) {
125
+ return [
126
+ '',
127
+ ' ┌─ Missing API Key ─────────────────────────────────────────────┐',
128
+ ' │ The Anthropic method requires an Anthropic API key. │',
129
+ ' │ │',
130
+ ' │ 1. Sign up at https://console.anthropic.com │',
131
+ ' │ 2. Run: export ANTHROPIC_API_KEY=sk-ant-... │',
132
+ ' │ 3. Or add to .env.local: ANTHROPIC_API_KEY=sk-ant-... │',
133
+ ' └────────────────────────────────────────────────────────────────┘',
134
+ ];
135
+ }
136
+ return this._apiFailureHelp('Anthropic console');
137
+ }
138
+ }
139
+
140
+ export { AnthropicMethod };
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Apertium Translation Method — free/open-source RULE-BASED MT via the public
3
+ * Apertium APy REST API. Mirrors the harness ApertiumMethod
4
+ * (arena/mt_eval_harness/methods/apertium.py): same endpoint, env vars, and
5
+ * GET/responseData shape, so config is interchangeable between CLI and harness.
6
+ *
7
+ * Endpoint: GET <base>/translate?langpair=<src>|<tgt>&q=<text>&markUnknown=no
8
+ * default https://apertium.org/apy
9
+ * Auth: none (public). Keyed APy deployments accept ?key=<key>.
10
+ * Env: APERTIUM_API_URL (optional), APERTIUM_API_KEY (optional)
11
+ * Locale: pass-through (accepts ISO 639-1 'es' or 639-3 'spa')
12
+ * License: GPL — called as a separate service, never bundled.
13
+ *
14
+ * Only installed pairs work (strongest on related languages). An uninstalled
15
+ * pair returns HTTP 400 and is surfaced honestly, never faked.
16
+ */
17
+
18
+ import { TranslationMethod } from './base.js';
19
+ import { getEnvOrFileVar } from '../api-key.js';
20
+ import { DEFAULT_METHOD_CONCURRENCY } from '../config.js';
21
+ import { extractContentBody } from './content-separator.js';
22
+ import { output } from '../output.js';
23
+ import { fetchWithRetry } from './fetch-with-retry.js';
24
+ import { pMap } from '../concurrent.js';
25
+
26
+ const APERTIUM_DEFAULT_BASE = 'https://apertium.org/apy';
27
+ const APERTIUM_USER_AGENT =
28
+ 'Champollion-MT-Eval/0.1 (+https://champollion.dev; champollion CLI)';
29
+ const APERTIUM_TIMEOUT_MS = 15000;
30
+
31
+ class ApertiumMethod extends TranslationMethod {
32
+ constructor(options = {}) {
33
+ super('apertium', options);
34
+ }
35
+
36
+ _resolveEndpoint(options = {}) {
37
+ let ep = options.apertiumApiUrl
38
+ || getEnvOrFileVar('APERTIUM_API_URL')
39
+ || getEnvOrFileVar('APERTIUM_API_URL', options.cwd)
40
+ || APERTIUM_DEFAULT_BASE;
41
+ ep = ep.replace(/\/+$/, '');
42
+ if (ep.endsWith('/translate')) ep = ep.slice(0, -'/translate'.length);
43
+ return ep;
44
+ }
45
+
46
+ _resolveApiKey(options = {}) {
47
+ return options.apertiumApiKey
48
+ || getEnvOrFileVar('APERTIUM_API_KEY')
49
+ || getEnvOrFileVar('APERTIUM_API_KEY', options.cwd);
50
+ }
51
+
52
+ _buildUrl(base, langpair, text, apiKey) {
53
+ const params = new URLSearchParams({ langpair, q: text, markUnknown: 'no' });
54
+ if (apiKey) params.set('key', apiKey);
55
+ return `${base}/translate?${params.toString()}`;
56
+ }
57
+
58
+ async _translateOne(base, langpair, text, apiKey, label) {
59
+ const res = await fetchWithRetry(
60
+ this._buildUrl(base, langpair, text, apiKey),
61
+ { method: 'GET', headers: { 'User-Agent': APERTIUM_USER_AGENT } },
62
+ { label, timeoutMs: APERTIUM_TIMEOUT_MS },
63
+ );
64
+ if (!res) return null;
65
+ if (!res.ok) {
66
+ const body = await res.text();
67
+ output.error(`Apertium ${label}: ${res.status} — ${body.slice(0, 200)}`);
68
+ return null;
69
+ }
70
+ const json = await res.json();
71
+ const translated = json?.responseData?.translatedText;
72
+ return typeof translated === 'string' ? translated : null;
73
+ }
74
+
75
+ async translate(keys, sourceFlat, pairConfig, options) {
76
+ const base = this._resolveEndpoint(options);
77
+ const apiKey = this._resolveApiKey(options);
78
+ const langpair = `${pairConfig.source || 'en'}|${pairConfig.target}`;
79
+ const allTranslated = {};
80
+
81
+ const items = keys.filter(
82
+ (k) => typeof sourceFlat[k] === 'string' && sourceFlat[k],
83
+ );
84
+ await pMap(items, async (key) => {
85
+ const out = await this._translateOne(base, langpair, sourceFlat[key], apiKey, `key ${key}`);
86
+ if (out !== null) allTranslated[key] = out;
87
+ }, { concurrency: DEFAULT_METHOD_CONCURRENCY });
88
+
89
+ return Object.keys(allTranslated).length > 0 ? allTranslated : null;
90
+ }
91
+
92
+ async translateContent(prompt, pairConfig, options) {
93
+ const base = this._resolveEndpoint(options);
94
+ const apiKey = this._resolveApiKey(options);
95
+ const bodyText = extractContentBody(prompt);
96
+ if (!bodyText.trim()) return null;
97
+ const langpair = `${pairConfig.source || 'en'}|${pairConfig.target}`;
98
+ return this._translateOne(base, langpair, bodyText, apiKey, 'content');
99
+ }
100
+
101
+ async checkReadiness(context) {
102
+ const base = this._resolveEndpoint(context || {});
103
+ try {
104
+ const controller = new AbortController();
105
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
106
+ const res = await fetch(`${base}/listPairs`, {
107
+ method: 'GET',
108
+ headers: { 'User-Agent': APERTIUM_USER_AGENT },
109
+ signal: controller.signal,
110
+ });
111
+ clearTimeout(timeoutId);
112
+ if (!res.ok) {
113
+ return { ready: false, reason: `Apertium API at ${base} responded ${res.status}.` };
114
+ }
115
+ return { ready: true };
116
+ } catch (err) {
117
+ return {
118
+ ready: false,
119
+ reason:
120
+ `Cannot reach Apertium API at ${base}: ` +
121
+ `${err.name === 'AbortError' ? 'timeout' : err.message}. ` +
122
+ `Override with APERTIUM_API_URL or self-host APy.`,
123
+ };
124
+ }
125
+ }
126
+
127
+ estimateCost(_keyCount) {
128
+ // The public Apertium API is free (rate-limited) — there is no per-character
129
+ // charge, so $0 is honest here (unlike local LLMs, whose compute cost is
130
+ // genuinely unknown).
131
+ return {
132
+ estimatedCost: 0,
133
+ currency: 'USD',
134
+ source: 'apertium-free-public-api',
135
+ note: 'Apertium public API is free (rate-limited; self-host for volume).',
136
+ };
137
+ }
138
+
139
+ getQualityTier() {
140
+ return 'standard';
141
+ }
142
+
143
+ getProvenance() {
144
+ return {
145
+ resources: [
146
+ { name: 'Apertium (rule-based MT)', license: 'GPL-3.0+', type: 'api' },
147
+ ],
148
+ commercialReady: false,
149
+ flags: ['rule-based', 'gpl'],
150
+ };
151
+ }
152
+
153
+ getSetupHelp() {
154
+ return [
155
+ ' Apertium (rule-based, free public API):',
156
+ ' • No API key needed — uses https://apertium.org/apy by default.',
157
+ ' • Only installed pairs work (strong on related languages, e.g. es↔ca, eng↔spa).',
158
+ ' • Self-host or point elsewhere: export APERTIUM_API_URL=https://your-apy',
159
+ ];
160
+ }
161
+ }
162
+
163
+ export { ApertiumMethod };
@@ -0,0 +1,316 @@
1
+ /**
2
+ * API Translation Method — thin HTTP client for remote translation endpoints.
3
+ *
4
+ * This method contains ZERO translation logic. It is purely a transport layer
5
+ * that delegates translation to a remote server. All prompts, coaching data,
6
+ * grammar rules, and linguistic pipelines live server-side.
7
+ *
8
+ * HOW IT WORKS:
9
+ * 1. Reads the endpoint URL from the plugin manifest
10
+ * 2. Reads API key from CHAMPOLLION_API_KEY env var
11
+ * 3. POSTs keys to the endpoint per the champollion API contract
12
+ * 4. Receives translations + billing metadata
13
+ * 5. Returns the key-value map to the sync pipeline
14
+ *
15
+ * WHY THIS IS A DUMB PIPE:
16
+ * The entire point of the API method is IP protection. The prompts,
17
+ * coaching data, and evaluation techniques stay on the server. This
18
+ * method ships in the open-source npm package and must contain nothing
19
+ * proprietary. It sends keys out, gets translations back. That's it.
20
+ *
21
+ * REQUEST FORMAT (what champollion sends):
22
+ * {
23
+ * source_locale: "en",
24
+ * target_locale: "crk",
25
+ * method: "crk-coached-v1",
26
+ * keys: { "hero.title": "Welcome", ... }
27
+ * }
28
+ *
29
+ * RESPONSE FORMAT (what the API returns):
30
+ * {
31
+ * translations: { "hero.title": "tawâw...", ... },
32
+ * meta: { model, cost_usd, quality_tier, ... }
33
+ * }
34
+ *
35
+ * COST PROFILE: Varies by method — determined server-side
36
+ * QUALITY TIER: Varies by method — read from plugin manifest
37
+ */
38
+
39
+ import { TranslationMethod } from './base.js';
40
+ import {
41
+ MAX_RETRIES, REQUEST_TIMEOUT_MS,
42
+ getBackoffDelay, sleep,
43
+ } from './http-utils.js';
44
+ import { pMap } from '../concurrent.js';
45
+ import { DEFAULT_METHOD_CONCURRENCY } from '../config.js';
46
+ import { output } from '../output.js';
47
+
48
+ // Maximum keys per API request (server-side limit)
49
+ const MAX_KEYS_PER_REQUEST = 100;
50
+
51
+ class APIMethod extends TranslationMethod {
52
+ constructor(options = {}) {
53
+ super('api', options);
54
+
55
+ // These come from the plugin manifest, set by the orchestrator
56
+ this.endpoint = options.endpoint || null;
57
+ this.methodName = options.methodName || null;
58
+ this.methodVersion = options.methodVersion || null;
59
+ this.qualityTier = options.qualityTier || 'standard';
60
+ this.pluginProvenance = options.provenance || null;
61
+ }
62
+
63
+ /**
64
+ * Translate a batch of key-value pairs via a remote API.
65
+ *
66
+ * @param {string[]} keys - Flat dot-notation keys to translate
67
+ * @param {object} sourceFlat - Full flattened source locale
68
+ * @param {object} pairConfig - Pair config (target, source, endpoint, etc.)
69
+ * @param {object} options - { apiKey } or reads from env
70
+ * @returns {object|null} Map of key → translated value, or null
71
+ */
72
+ async translate(keys, sourceFlat, pairConfig, options) {
73
+ const apiKey = options.apiKey
74
+ || process.env.CHAMPOLLION_API_KEY;
75
+
76
+ if (!apiKey) {
77
+ output.error('API method: No API key found.');
78
+ output.error('Set CHAMPOLLION_API_KEY in your environment.');
79
+ return null;
80
+ }
81
+
82
+ // Endpoint comes from the plugin manifest or the pair config
83
+ const endpoint = this.endpoint
84
+ || pairConfig.endpoint
85
+ || options.endpoint;
86
+
87
+ if (!endpoint) {
88
+ output.error('API method: No endpoint configured.');
89
+ output.error('Install a plugin: champollion plugin install <method-name>');
90
+ return null;
91
+ }
92
+
93
+ const sourceLocale = pairConfig.source || 'en';
94
+ const targetLocale = pairConfig.target;
95
+ const method = this.methodName || pairConfig.methodPlugin || 'default';
96
+
97
+ const allTranslated = {};
98
+
99
+ const batchChunks = [];
100
+ for (let i = 0; i < keys.length; i += MAX_KEYS_PER_REQUEST) {
101
+ batchChunks.push(keys.slice(i, i + MAX_KEYS_PER_REQUEST));
102
+ }
103
+
104
+ await pMap(batchChunks, async (chunk, idx) => {
105
+ // Build the key-value payload for this batch
106
+ const keysPayload = {};
107
+ for (const key of chunk) {
108
+ const value = sourceFlat[key];
109
+ if (value && typeof value === 'string') {
110
+ keysPayload[key] = value;
111
+ }
112
+ }
113
+
114
+ if (Object.keys(keysPayload).length === 0) return;
115
+
116
+ const result = await this._translateBatchWithRetry(
117
+ keysPayload,
118
+ sourceLocale,
119
+ targetLocale,
120
+ method,
121
+ endpoint,
122
+ apiKey,
123
+ idx + 1,
124
+ );
125
+
126
+ if (result) {
127
+ Object.assign(allTranslated, result);
128
+ }
129
+ }, { concurrency: DEFAULT_METHOD_CONCURRENCY });
130
+
131
+ return Object.keys(allTranslated).length > 0 ? allTranslated : null;
132
+ }
133
+
134
+ /**
135
+ * Freeform content translation via the API.
136
+ *
137
+ * The API method could support this in the future, but for now
138
+ * we only support key-value translation. Return null so the
139
+ * orchestrator falls back to the local LLM method.
140
+ */
141
+ async translateContent(_prompt, _pairConfig, _options) {
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * POST to the remote API with exponential backoff retry.
147
+ *
148
+ * @param {object} keysPayload - Map of key → source value
149
+ * @param {string} sourceLocale - Source language code
150
+ * @param {string} targetLocale - Target language code
151
+ * @param {string} method - Method name from plugin manifest
152
+ * @param {string} endpoint - API endpoint URL
153
+ * @param {string} apiKey - Remote API key
154
+ * @param {number} batchNum - Batch number for logging
155
+ * @returns {object|null} Map of key → translated value
156
+ */
157
+ async _translateBatchWithRetry(keysPayload, sourceLocale, targetLocale, method, endpoint, apiKey, batchNum) {
158
+ const keyCount = Object.keys(keysPayload).length;
159
+
160
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
161
+ try {
162
+ const controller = new AbortController();
163
+ const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
164
+
165
+ const response = await fetch(endpoint, {
166
+ method: 'POST',
167
+ headers: {
168
+ 'Authorization': `Bearer ${apiKey}`,
169
+ 'Content-Type': 'application/json',
170
+ 'User-Agent': 'champollion',
171
+ },
172
+ body: JSON.stringify({
173
+ source_locale: sourceLocale,
174
+ target_locale: targetLocale,
175
+ method,
176
+ keys: keysPayload,
177
+ }),
178
+ signal: controller.signal,
179
+ });
180
+
181
+ clearTimeout(timeoutId);
182
+
183
+ // Handle rate limiting with retry
184
+ if (response.status === 429) {
185
+ if (attempt < MAX_RETRIES) {
186
+ // Respect Retry-After header if present
187
+ const retryAfter = response.headers.get('Retry-After');
188
+ const delay = retryAfter
189
+ ? parseInt(retryAfter, 10) * 1000
190
+ : getBackoffDelay(attempt);
191
+ output.warn(`⏳ API batch ${batchNum}: Rate limited — retrying in ${Math.round(delay / 1000)}s...`);
192
+ await sleep(delay);
193
+ continue;
194
+ }
195
+ output.error(`API batch ${batchNum}: Rate limited after ${MAX_RETRIES + 1} attempts`);
196
+ return null;
197
+ }
198
+
199
+ // Handle server errors with retry
200
+ if (response.status >= 500) {
201
+ if (attempt < MAX_RETRIES) {
202
+ const delay = getBackoffDelay(attempt);
203
+ output.warn(`⏳ API batch ${batchNum}: ${response.status} — retrying in ${Math.round(delay / 1000)}s...`);
204
+ await sleep(delay);
205
+ continue;
206
+ }
207
+ output.error(`API batch ${batchNum}: ${response.status} after ${MAX_RETRIES + 1} attempts`);
208
+ return null;
209
+ }
210
+
211
+ // Handle auth errors (no retry)
212
+ if (response.status === 401) {
213
+ const body = await response.json().catch(() => ({}));
214
+ output.error(`API method: Unauthorized — ${body.error?.message || 'Invalid API key'}`);
215
+ output.error('Check your CHAMPOLLION_API_KEY environment variable.');
216
+ return null;
217
+ }
218
+
219
+ // Handle payment required (no retry)
220
+ if (response.status === 402) {
221
+ const body = await response.json().catch(() => ({}));
222
+ output.error(`API method: ${body.error?.message || 'Payment required — usage limit exceeded.'}`);
223
+ return null;
224
+ }
225
+
226
+ // Handle method not found (no retry)
227
+ if (response.status === 404) {
228
+ const body = await response.json().catch(() => ({}));
229
+ output.error(`API method: Method "${method}" not found — ${body.error?.message || 'Unknown method'}`);
230
+ return null;
231
+ }
232
+
233
+ // Handle other client errors (no retry)
234
+ if (!response.ok) {
235
+ const body = await response.json().catch(() => ({}));
236
+ output.error(`API batch ${batchNum}: ${response.status} — ${body.error?.message || 'Unknown error'}`);
237
+ return null;
238
+ }
239
+
240
+ const json = await response.json();
241
+
242
+ // Handle partial success (207)
243
+ if (response.status === 207 && json.errors) {
244
+ const errorCount = Object.keys(json.errors).length;
245
+ output.warn(`API batch ${batchNum}: ${errorCount} key(s) failed`);
246
+ for (const [key, err] of Object.entries(json.errors)) {
247
+ output.warn(`${key}: ${err.message}`);
248
+ }
249
+ }
250
+
251
+ if (!json.translations || typeof json.translations !== 'object') {
252
+ output.error(`API batch ${batchNum}: Invalid response — no translations object`);
253
+ return null;
254
+ }
255
+
256
+ const translatedCount = Object.keys(json.translations).length;
257
+ const costStr = json.meta?.cost_usd ? ` $${json.meta.cost_usd.toFixed(4)}` : '';
258
+ output.info(`✓ API batch ${batchNum} (${translatedCount}/${keyCount} keys${costStr})`);
259
+
260
+ return json.translations;
261
+
262
+ } catch (err) {
263
+ if (err.name === 'AbortError') {
264
+ if (attempt < MAX_RETRIES) {
265
+ output.warn(`⏳ API batch ${batchNum}: Timeout — retrying...`);
266
+ continue;
267
+ }
268
+ output.error(`API batch ${batchNum}: Timeout after ${MAX_RETRIES + 1} attempts`);
269
+ return null;
270
+ }
271
+
272
+ if (attempt < MAX_RETRIES) {
273
+ const delay = getBackoffDelay(attempt);
274
+ output.warn(`⏳ API batch ${batchNum}: ${err.message} — retrying in ${Math.round(delay / 1000)}s...`);
275
+ await sleep(delay);
276
+ } else {
277
+ output.error(`API batch ${batchNum}: ${err.message} after ${MAX_RETRIES + 1} attempts`);
278
+ return null;
279
+ }
280
+ }
281
+ }
282
+ return null;
283
+ }
284
+
285
+ /**
286
+ * Cost estimation — API method pricing is determined by the remote server.
287
+ * We cannot estimate cost without querying the endpoint.
288
+ */
289
+ estimateCost(keyCount) {
290
+ return {
291
+ estimatedCost: null,
292
+ currency: 'USD',
293
+ source: 'server-determined',
294
+ note: 'Cost is determined by the remote API. Contact the provider for pricing.',
295
+ };
296
+ }
297
+
298
+ getQualityTier() {
299
+ return this.qualityTier;
300
+ }
301
+
302
+ getProvenance() {
303
+ if (this.pluginProvenance) {
304
+ return this.pluginProvenance;
305
+ }
306
+ return {
307
+ resources: [
308
+ { name: 'Remote Translation API', license: 'Provider ToS', type: 'api' },
309
+ ],
310
+ commercialReady: true,
311
+ flags: [],
312
+ };
313
+ }
314
+ }
315
+
316
+ export { APIMethod };