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
package/lib/methods/local.js
CHANGED
|
@@ -13,7 +13,34 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { OpenAIMethod } from './openai.js';
|
|
16
|
-
import {
|
|
16
|
+
import { isLoopbackEndpoint, localMachineCost } from './http-utils.js';
|
|
17
|
+
import { onCIRunner } from '../missing-key.js';
|
|
18
|
+
|
|
19
|
+
/** How long the readiness probe waits for a local endpoint to answer. */
|
|
20
|
+
const PROBE_TIMEOUT_MS = 3000;
|
|
21
|
+
// One probe per endpoint per process: a project with ten local pairs asks once.
|
|
22
|
+
const probes = new Map();
|
|
23
|
+
|
|
24
|
+
/** Does anything answer HTTP at `<base>/models`? (Any status counts.) */
|
|
25
|
+
function endpointAnswers(base) {
|
|
26
|
+
if (!probes.has(base)) {
|
|
27
|
+
probes.set(base, (async () => {
|
|
28
|
+
const ctl = new AbortController();
|
|
29
|
+
const timer = setTimeout(() => ctl.abort(), PROBE_TIMEOUT_MS);
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(`${base}/models`, { signal: ctl.signal });
|
|
32
|
+
// Drain it: the answer is all that matters, not the body.
|
|
33
|
+
try { await res.arrayBuffer(); } catch { /* the status was enough */ }
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
} finally {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
}
|
|
40
|
+
})());
|
|
41
|
+
}
|
|
42
|
+
return probes.get(base);
|
|
43
|
+
}
|
|
17
44
|
|
|
18
45
|
class LocalMethod extends OpenAIMethod {
|
|
19
46
|
constructor(options = {}) {
|
|
@@ -24,13 +51,16 @@ class LocalMethod extends OpenAIMethod {
|
|
|
24
51
|
_getProviderLabel() { return 'Local (OpenAI-compatible)'; }
|
|
25
52
|
_getApiKeyEnvVar() { return 'OPENAI_API_KEY'; }
|
|
26
53
|
_getDefaultModel() { return 'llama3.1'; }
|
|
54
|
+
// A local server names its models itself ("llama3.1:8b",
|
|
55
|
+
// "Qwen/Qwen2.5-7B-Instruct"): ids are sent as written, never mapped.
|
|
56
|
+
_getModelVendor() { return null; }
|
|
27
57
|
|
|
28
58
|
// Endpoint precedence: options.baseUrl > LOCAL_API_BASE > OPENAI_API_BASE >
|
|
29
|
-
// Ollama default. (Matches the harness LocalProvider resolution order.)
|
|
59
|
+
// OPENAI_BASE_URL (the OpenAI SDK's name) > Ollama default. (Matches the harness LocalProvider resolution order.)
|
|
30
60
|
_getApiBaseEnvVar() { return 'LOCAL_API_BASE'; }
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
61
|
+
_getApiBaseEnvVars() { return ['LOCAL_API_BASE', 'OPENAI_API_BASE', 'OPENAI_BASE_URL']; }
|
|
62
|
+
_getDefaultApiBase() { return 'http://localhost:11434/v1'; }
|
|
63
|
+
_getDefaultApiBaseLabel() { return 'the default, Ollama'; }
|
|
34
64
|
|
|
35
65
|
// Local servers ignore auth — supply a placeholder when no key is set so
|
|
36
66
|
// translate() doesn't skip the run for a "missing" key.
|
|
@@ -38,12 +68,60 @@ class LocalMethod extends OpenAIMethod {
|
|
|
38
68
|
return super._resolveApiKey(options) || 'not-needed';
|
|
39
69
|
}
|
|
40
70
|
|
|
41
|
-
|
|
42
|
-
|
|
71
|
+
/**
|
|
72
|
+
* A local endpoint needs no key — but it must answer. Checked even when
|
|
73
|
+
* nothing is queued (as hosted methods' keys are): a project whose config
|
|
74
|
+
* says "local", synced in CI with the guide's default line, went green on
|
|
75
|
+
* every push that changed no string and failed only on the first that did
|
|
76
|
+
* (Round 8, i18next persona). Any HTTP answer from `<base>/models` counts
|
|
77
|
+
* (a server that does not list models still answers); refused, unknown
|
|
78
|
+
* host or no answer within a few seconds does not. A dry run reports it
|
|
79
|
+
* as a warning (lib/sync.js preflight).
|
|
80
|
+
*
|
|
81
|
+
* Off a CI runner the answer is `unreachable: true` (with the `endpoint`):
|
|
82
|
+
* sync decides after its plan — a run that sends nothing to the model (a
|
|
83
|
+
* redo served from the cache) goes on with a warning, one that sends
|
|
84
|
+
* something stops as before (Round 11, Django persona). On a runner it
|
|
85
|
+
* still stops at once, for the reason above.
|
|
86
|
+
*/
|
|
87
|
+
async checkReadiness({ cwd } = {}) {
|
|
88
|
+
let resolved;
|
|
89
|
+
try { resolved = this._resolveApiBaseSource(cwd ? { cwd } : {}); } catch { resolved = { base: null, from: null }; }
|
|
90
|
+
if (!resolved.base) return { ready: true };
|
|
91
|
+
if (await endpointAnswers(resolved.base)) return { ready: true };
|
|
92
|
+
const where = this._describeEndpoint(cwd ? { cwd } : {}) || resolved.base;
|
|
93
|
+
const inCI = onCIRunner();
|
|
94
|
+
return {
|
|
95
|
+
ready: false,
|
|
96
|
+
unreachable: true,
|
|
97
|
+
endpoint: where,
|
|
98
|
+
reason: `the config uses the "local" method, and no model server answers at ${where}. `
|
|
99
|
+
+ (inCI
|
|
100
|
+
? 'A CI runner has no model server: pass --method llm (with OPENROUTER_API_KEY added as a repository secret and passed to the sync step), '
|
|
101
|
+
+ 'or start one in the job and set LOCAL_API_BASE to it'
|
|
102
|
+
: 'Start it (e.g. `ollama serve`), set LOCAL_API_BASE to a server that runs, or pass --method llm to use a hosted model'),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// No model listing. The inherited OpenAI listing called
|
|
107
|
+
// api.openai.com/v1/models — a request off the machine on every "local"
|
|
108
|
+
// run, the one method people choose to stay on it (found 2026-10-03). A
|
|
109
|
+
// local server's own /models is not a reliable allow-list either (many
|
|
110
|
+
// serve whatever model name is asked), so the model is not pre-validated.
|
|
111
|
+
async _fetchModels(_apiKey) {
|
|
112
|
+
return null;
|
|
43
113
|
}
|
|
44
114
|
|
|
45
|
-
//
|
|
46
|
-
|
|
115
|
+
// A model served on THIS machine (Ollama's default, LM Studio, a forge
|
|
116
|
+
// model on 127.0.0.1) costs $0 in API fees — said as such, with what is not
|
|
117
|
+
// counted. Any other endpoint (Groq, Together, a LAN box) is genuinely
|
|
118
|
+
// unknown to the tool — never $0.
|
|
119
|
+
estimateCost(_keyCount, pairConfig = {}, { cwd = null } = {}) {
|
|
120
|
+
const base = this._resolveApiBase({
|
|
121
|
+
...(pairConfig?.baseUrl ? { baseUrl: pairConfig.baseUrl } : {}),
|
|
122
|
+
...(cwd && { cwd }),
|
|
123
|
+
});
|
|
124
|
+
if (isLoopbackEndpoint(base)) return localMachineCost(base);
|
|
47
125
|
return {
|
|
48
126
|
estimatedCost: null,
|
|
49
127
|
currency: 'USD',
|
|
@@ -63,11 +141,16 @@ class LocalMethod extends OpenAIMethod {
|
|
|
63
141
|
}
|
|
64
142
|
|
|
65
143
|
getSetupHelp() {
|
|
144
|
+
// Name the endpoint this run used and the setting that chose it: "fetch
|
|
145
|
+
// failed" alone left people guessing which of four settings was in play.
|
|
146
|
+
const endpoint = this._describeEndpoint();
|
|
66
147
|
return [
|
|
67
148
|
' Local (OpenAI-compatible) endpoint:',
|
|
149
|
+
...(endpoint ? [` • This run sent requests to ${endpoint}.`] : []),
|
|
68
150
|
' • Ollama: install from https://ollama.com, run `ollama serve`,',
|
|
69
151
|
' then `ollama pull llama3.1` (default http://localhost:11434/v1).',
|
|
70
|
-
' • Or point at vLLM / LM Studio / Groq via LOCAL_API_BASE
|
|
152
|
+
' • Or point at vLLM / LM Studio / Groq via LOCAL_API_BASE (checked first),',
|
|
153
|
+
' OPENAI_API_BASE or OPENAI_BASE_URL — in the environment or .env.local / .env —',
|
|
71
154
|
' and set the model with --model (e.g. qwen2.5, mistral).',
|
|
72
155
|
];
|
|
73
156
|
}
|
|
@@ -22,6 +22,7 @@ const MICROSOFT_REQUEST_TIMEOUT_MS = 15000;
|
|
|
22
22
|
class MicrosoftTranslatorMethod extends TranslationMethod {
|
|
23
23
|
constructor(options = {}) {
|
|
24
24
|
super('microsoft-translator', options);
|
|
25
|
+
this.translatesRawText = true; // see base.js
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
// ── API resolution helpers ──────────────────────────────────────
|
|
@@ -47,7 +48,6 @@ class MicrosoftTranslatorMethod extends TranslationMethod {
|
|
|
47
48
|
*/
|
|
48
49
|
_resolveRegion(options) {
|
|
49
50
|
return options.microsoftRegion
|
|
50
|
-
|| getEnvOrFileVar('MICROSOFT_TRANSLATOR_REGION')
|
|
51
51
|
|| getEnvOrFileVar('MICROSOFT_TRANSLATOR_REGION', options.cwd);
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -66,7 +66,6 @@ class MicrosoftTranslatorMethod extends TranslationMethod {
|
|
|
66
66
|
*/
|
|
67
67
|
_resolveEndpoint(options = {}) {
|
|
68
68
|
let ep = options.microsoftEndpoint
|
|
69
|
-
|| getEnvOrFileVar('MICROSOFT_TRANSLATOR_ENDPOINT')
|
|
70
69
|
|| getEnvOrFileVar('MICROSOFT_TRANSLATOR_ENDPOINT', options.cwd);
|
|
71
70
|
if (!ep) return MICROSOFT_API_BASE;
|
|
72
71
|
ep = ep.replace(/\/+$/, '');
|
package/lib/methods/openai.js
CHANGED
|
@@ -33,11 +33,12 @@ class OpenAIMethod extends DirectLLMMethod {
|
|
|
33
33
|
_getApiKeyOptionsKey() { return 'openaiApiKey'; }
|
|
34
34
|
_getDefaultModel() { return DEFAULT_MODEL; }
|
|
35
35
|
_getProviderLabel() { return 'OpenAI'; }
|
|
36
|
+
_getModelVendor() { return 'openai'; }
|
|
36
37
|
_getDefaultApiBase() { return 'https://api.openai.com/v1'; }
|
|
37
38
|
|
|
38
39
|
// ── API request/response shape ───────────────────────────────────
|
|
39
40
|
|
|
40
|
-
_buildApiRequest({ prompt, systemMessage, apiKey, model, temperature, isJsonMode }) {
|
|
41
|
+
_buildApiRequest({ prompt, systemMessage, apiKey, model, temperature, isJsonMode, cwd = null }) {
|
|
41
42
|
const messages = systemMessage
|
|
42
43
|
? [{ role: 'system', content: systemMessage }, { role: 'user', content: prompt }]
|
|
43
44
|
: [{ role: 'user', content: prompt }];
|
|
@@ -47,7 +48,8 @@ class OpenAIMethod extends DirectLLMMethod {
|
|
|
47
48
|
body.response_format = { type: 'json_object' };
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
// The project's .env (cwd) may point it at a gateway or a local server.
|
|
52
|
+
const base = this._resolveApiBase(cwd ? { cwd } : {}) || 'https://api.openai.com/v1';
|
|
51
53
|
return {
|
|
52
54
|
url: `${base}/chat/completions`,
|
|
53
55
|
headers: {
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { DEFAULT_TEMPERATURE } from '../config.js';
|
|
22
22
|
import { output } from '../output.js';
|
|
23
23
|
import { recordTranslationError } from './translation-error.js';
|
|
24
|
+
import { captureRequest } from './request-capture.js';
|
|
24
25
|
|
|
25
26
|
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
26
27
|
|
|
@@ -55,32 +56,32 @@ async function callOpenRouter({
|
|
|
55
56
|
xTitle = 'champollion',
|
|
56
57
|
systemMessage = null,
|
|
57
58
|
}) {
|
|
59
|
+
// Build messages array — when systemMessage is provided, split preamble
|
|
60
|
+
// from payload to enable provider-level prompt caching. The system message
|
|
61
|
+
// (register + rules) is identical across batches for a given locale, so
|
|
62
|
+
// providers like Anthropic and Google cache it automatically.
|
|
63
|
+
const messages = systemMessage
|
|
64
|
+
? [{ role: 'system', content: systemMessage }, { role: 'user', content: prompt }]
|
|
65
|
+
: [{ role: 'user', content: prompt }];
|
|
66
|
+
const headers = {
|
|
67
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
68
|
+
'Content-Type': 'application/json',
|
|
69
|
+
'HTTP-Referer': 'https://github.com/gamedaysuits/Champollion',
|
|
70
|
+
'X-Title': xTitle,
|
|
71
|
+
};
|
|
72
|
+
const body = { model, messages, temperature };
|
|
73
|
+
// `sync --dry --show-prompt`: hand over the exact request, send nothing.
|
|
74
|
+
if (captureRequest({ url: OPENROUTER_URL, headers, body })) return null;
|
|
75
|
+
|
|
58
76
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
59
77
|
try {
|
|
60
78
|
const controller = new AbortController();
|
|
61
79
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
62
80
|
|
|
63
|
-
// Build messages array — when systemMessage is provided, split preamble
|
|
64
|
-
// from payload to enable provider-level prompt caching. The system message
|
|
65
|
-
// (register + rules) is identical across batches for a given locale, so
|
|
66
|
-
// providers like Anthropic and Google cache it automatically.
|
|
67
|
-
const messages = systemMessage
|
|
68
|
-
? [{ role: 'system', content: systemMessage }, { role: 'user', content: prompt }]
|
|
69
|
-
: [{ role: 'user', content: prompt }];
|
|
70
|
-
|
|
71
81
|
const response = await fetch(OPENROUTER_URL, {
|
|
72
82
|
method: 'POST',
|
|
73
|
-
headers
|
|
74
|
-
|
|
75
|
-
'Content-Type': 'application/json',
|
|
76
|
-
'HTTP-Referer': 'https://github.com/gamedaysuits/Champollion',
|
|
77
|
-
'X-Title': xTitle,
|
|
78
|
-
},
|
|
79
|
-
body: JSON.stringify({
|
|
80
|
-
model,
|
|
81
|
-
messages,
|
|
82
|
-
temperature,
|
|
83
|
-
}),
|
|
83
|
+
headers,
|
|
84
|
+
body: JSON.stringify(body),
|
|
84
85
|
signal: controller.signal,
|
|
85
86
|
});
|
|
86
87
|
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { EST_INPUT_TOKENS_PER_KEY, EST_OUTPUT_TOKENS_PER_KEY } from '../config.js';
|
|
23
|
+
import { editDistance } from '../edit-distance.js';
|
|
23
24
|
|
|
24
25
|
const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models';
|
|
25
26
|
|
|
@@ -30,6 +31,13 @@ const COACHED_INPUT_MULTIPLIER = 2.5;
|
|
|
30
31
|
// In-memory cache: fetched once per process
|
|
31
32
|
let _pricingCache = null;
|
|
32
33
|
let _pricingFetchPromise = null;
|
|
34
|
+
// What the last fetch saw, beside the prices: whether the list came at all
|
|
35
|
+
// (and why not), and every model id it listed — priced or not — so an
|
|
36
|
+
// unpriced estimate can say WHICH it is: a name the list does not have (a
|
|
37
|
+
// likely typo), a listed model with no per-token price, or no list at all.
|
|
38
|
+
// A mistyped slug used to read exactly like an unpriced model: "unknown (no
|
|
39
|
+
// method in this run has published pricing)" (Round 11, Next.js persona).
|
|
40
|
+
let _catalog = { fetched: false, why: 'not fetched yet', ids: new Set() };
|
|
33
41
|
|
|
34
42
|
/**
|
|
35
43
|
* Fetch pricing for all OpenRouter models.
|
|
@@ -56,6 +64,13 @@ async function fetchModelPricing() {
|
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
async function _doFetch() {
|
|
67
|
+
// The live pricing draw is off (an air-gapped node — the same switch the
|
|
68
|
+
// direct providers' pricing honours, lib/methods/provider-pricing.js
|
|
69
|
+
// pricingOffline): no list, and the estimate says so.
|
|
70
|
+
if (process.env.CHAMPOLLION_PRICING_OFFLINE === '1') {
|
|
71
|
+
_catalog = { fetched: false, why: 'the live price draw is off: CHAMPOLLION_PRICING_OFFLINE=1', ids: new Set() };
|
|
72
|
+
return new Map();
|
|
73
|
+
}
|
|
59
74
|
try {
|
|
60
75
|
const controller = new AbortController();
|
|
61
76
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
@@ -67,29 +82,93 @@ async function _doFetch() {
|
|
|
67
82
|
|
|
68
83
|
clearTimeout(timeoutId);
|
|
69
84
|
|
|
70
|
-
if (!response.ok)
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
_catalog = { fetched: false, why: `OpenRouter answered HTTP ${response.status}`, ids: new Set() };
|
|
87
|
+
return new Map();
|
|
88
|
+
}
|
|
71
89
|
|
|
72
90
|
const json = await response.json();
|
|
73
91
|
const models = json.data || [];
|
|
74
92
|
const pricing = new Map();
|
|
93
|
+
const ids = new Set();
|
|
75
94
|
|
|
76
95
|
for (const model of models) {
|
|
77
|
-
if (model.id
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
96
|
+
if (!model.id) continue;
|
|
97
|
+
ids.add(model.id);
|
|
98
|
+
if (!model.pricing) continue;
|
|
99
|
+
const input = parseFloat(model.pricing.prompt);
|
|
100
|
+
const output = parseFloat(model.pricing.completion);
|
|
101
|
+
// A router whose price depends on the model it picks lists "-1": no
|
|
102
|
+
// per-token price — never a negative estimate (listed, unpriced).
|
|
103
|
+
if (!Number.isFinite(input) || !Number.isFinite(output) || input < 0 || output < 0) continue;
|
|
104
|
+
pricing.set(model.id, { input, output });
|
|
83
105
|
}
|
|
84
106
|
|
|
107
|
+
// When the list was read: an estimate says which day's prices it used.
|
|
108
|
+
_catalog = { fetched: true, why: null, ids, fetchedAt: new Date().toISOString() };
|
|
85
109
|
return pricing;
|
|
86
|
-
} catch {
|
|
110
|
+
} catch (err) {
|
|
87
111
|
// Offline, timeout, or API issue — return empty map
|
|
88
|
-
// Cost estimation degrades gracefully to "unknown"
|
|
112
|
+
// Cost estimation degrades gracefully to "unknown", and says why.
|
|
113
|
+
_catalog = {
|
|
114
|
+
fetched: false,
|
|
115
|
+
why: err?.name === 'AbortError' ? 'the request timed out' : `the request failed (${err?.message || err})`,
|
|
116
|
+
ids: new Set(),
|
|
117
|
+
};
|
|
89
118
|
return new Map();
|
|
90
119
|
}
|
|
91
120
|
}
|
|
92
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The listed model ids closest to a name the list does not have — a likely
|
|
124
|
+
* typo's intended slug. Cheap: one bounded edit distance per listed id.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} model
|
|
127
|
+
* @param {Iterable<string>} ids
|
|
128
|
+
* @param {number} [n=3]
|
|
129
|
+
* @returns {string[]}
|
|
130
|
+
*/
|
|
131
|
+
function closestModelIds(model, ids, n = 3) {
|
|
132
|
+
const want = String(model).toLowerCase();
|
|
133
|
+
const max = Math.max(2, Math.floor(want.length / 5));
|
|
134
|
+
const scored = [];
|
|
135
|
+
for (const id of ids) {
|
|
136
|
+
const d = editDistance(id.toLowerCase(), want, max);
|
|
137
|
+
if (d <= max) scored.push([d, id]);
|
|
138
|
+
}
|
|
139
|
+
scored.sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1]));
|
|
140
|
+
return scored.slice(0, n).map(([, id]) => id);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Why a model has no price, in one sentence that names it — and the
|
|
145
|
+
* estimate's `source` for that case.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} model
|
|
148
|
+
* @returns {{ source: string, note: string, closest?: string[] }}
|
|
149
|
+
*/
|
|
150
|
+
function unpricedReason(model) {
|
|
151
|
+
if (!_catalog.fetched) {
|
|
152
|
+
return {
|
|
153
|
+
source: 'openrouter-price-list-unavailable',
|
|
154
|
+
note: `OpenRouter's price list could not be read (${_catalog.why}), so the price of "${model}" is unknown.`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (_catalog.ids.has(model)) {
|
|
158
|
+
return {
|
|
159
|
+
source: 'openrouter-no-price',
|
|
160
|
+
note: `"${model}" is in OpenRouter's model list but has no published per-token price, so its cost cannot be estimated.`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const closest = closestModelIds(model, _catalog.ids);
|
|
164
|
+
return {
|
|
165
|
+
source: 'openrouter-not-listed',
|
|
166
|
+
note: `"${model}" is not in OpenRouter's model list — likely a typo in the model name`
|
|
167
|
+
+ (closest.length > 0 ? `. Closest listed: ${closest.join(', ')}.` : ' (nothing listed is close to it).'),
|
|
168
|
+
closest,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
93
172
|
/**
|
|
94
173
|
* Estimate cost for translating N keys with a specific model via OpenRouter.
|
|
95
174
|
*
|
|
@@ -115,11 +194,16 @@ async function estimateOpenRouterCost(keyCount, model, options = {}) {
|
|
|
115
194
|
|
|
116
195
|
const modelPricing = pricing.get(model);
|
|
117
196
|
if (!modelPricing) {
|
|
197
|
+
// Named, and why: not in the list (a likely typo — with the closest
|
|
198
|
+
// listed slugs), listed with no price, or no list to look in.
|
|
199
|
+
const why = unpricedReason(model);
|
|
118
200
|
return {
|
|
119
201
|
estimatedCost: null,
|
|
120
202
|
currency: 'USD',
|
|
121
|
-
source:
|
|
122
|
-
note:
|
|
203
|
+
source: why.source,
|
|
204
|
+
note: why.note,
|
|
205
|
+
model,
|
|
206
|
+
...(why.closest && { closest: why.closest }),
|
|
123
207
|
};
|
|
124
208
|
}
|
|
125
209
|
|
|
@@ -136,21 +220,74 @@ async function estimateOpenRouterCost(keyCount, model, options = {}) {
|
|
|
136
220
|
const inputCost = totalInputTokens * modelPricing.input;
|
|
137
221
|
const outputCost = totalOutputTokens * modelPricing.output;
|
|
138
222
|
const totalCost = inputCost + outputCost;
|
|
223
|
+
const rate = openRouterRate(model, modelPricing, { input: inputTokensPerKey, output: outputTokensPerKey });
|
|
139
224
|
|
|
140
225
|
return {
|
|
141
226
|
estimatedCost: Math.round(totalCost * 10000) / 10000,
|
|
142
227
|
currency: 'USD',
|
|
143
228
|
source: `openrouter (${model})`,
|
|
144
|
-
note: `Based on ${model} pricing: $${
|
|
229
|
+
note: `Based on ${model} pricing: $${formatPerMillion(rate.inputPerMillion)}/1M input tokens, `
|
|
230
|
+
+ `$${formatPerMillion(rate.outputPerMillion)}/1M output tokens (OpenRouter's price list, read ${rate.fetchedAt}).`,
|
|
231
|
+
rate,
|
|
145
232
|
};
|
|
146
233
|
}
|
|
147
234
|
|
|
235
|
+
/**
|
|
236
|
+
* The rate an estimate used, said in full: what one million tokens cost in
|
|
237
|
+
* and out, where the figure came from (OpenRouter's public price list) and
|
|
238
|
+
* when it was read, and the tokens per key the estimate assumes. An
|
|
239
|
+
* estimate used to give a figure with none of this (Round 14, Next.js
|
|
240
|
+
* persona) — no way to tell a stale or mistaken price from a real one.
|
|
241
|
+
*
|
|
242
|
+
* @param {string} model - The OpenRouter model id priced
|
|
243
|
+
* @param {{ input: number, output: number }} perToken - USD per token
|
|
244
|
+
* @param {{ input: number, output: number }} tokensPerKey - What the estimate assumes
|
|
245
|
+
* @returns {{ model: string, unit: 'token', inputPerMillion: number, outputPerMillion: number,
|
|
246
|
+
* tokensPerKey: { input: number, output: number }, from: string, url: string, fetchedAt: string|null }}
|
|
247
|
+
*/
|
|
248
|
+
function openRouterRate(model, perToken, tokensPerKey) {
|
|
249
|
+
return {
|
|
250
|
+
model,
|
|
251
|
+
unit: 'token',
|
|
252
|
+
inputPerMillion: perMillion(perToken.input),
|
|
253
|
+
outputPerMillion: perMillion(perToken.output),
|
|
254
|
+
tokensPerKey,
|
|
255
|
+
from: 'openrouter-price-list',
|
|
256
|
+
url: OPENROUTER_MODELS_URL,
|
|
257
|
+
fetchedAt: _catalog.fetchedAt || null,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** USD per token → USD per million tokens, without float noise (0.0000003 → 0.3). */
|
|
262
|
+
function perMillion(perTokenUsd) {
|
|
263
|
+
return Number((perTokenUsd * 1_000_000).toPrecision(12));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** $ per 1M, as people write it: 0.3 → "0.30", 2.5 → "2.50", 0.075 → "0.075". */
|
|
267
|
+
function formatPerMillion(usd) {
|
|
268
|
+
return usd >= 0.01 && Number(usd.toFixed(2)) === usd ? usd.toFixed(2) : String(usd);
|
|
269
|
+
}
|
|
270
|
+
|
|
148
271
|
/**
|
|
149
272
|
* Clear the pricing cache. Useful for testing.
|
|
150
273
|
*/
|
|
151
274
|
function clearPricingCache() {
|
|
152
275
|
_pricingCache = null;
|
|
153
276
|
_pricingFetchPromise = null;
|
|
277
|
+
_catalog = { fetched: false, why: 'not fetched yet', ids: new Set() };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* When OpenRouter's price list was read in this process (ISO 8601), or
|
|
282
|
+
* null when it was not (offline, failed, never asked).
|
|
283
|
+
*
|
|
284
|
+
* @returns {string|null}
|
|
285
|
+
*/
|
|
286
|
+
function priceListFetchedAt() {
|
|
287
|
+
return _catalog.fetched ? (_catalog.fetchedAt || null) : null;
|
|
154
288
|
}
|
|
155
289
|
|
|
156
|
-
export {
|
|
290
|
+
export {
|
|
291
|
+
fetchModelPricing, estimateOpenRouterCost, clearPricingCache, priceListFetchedAt, formatPerMillion,
|
|
292
|
+
OPENROUTER_MODELS_URL,
|
|
293
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prompt-methods.js — which methods send the `llm` method's prompt, and so
|
|
3
|
+
* carry a pair's free-text coaching (coachingFile / coachingPrompt).
|
|
4
|
+
*
|
|
5
|
+
* Dependency-free on purpose: the translation-memory key (lib/tm.js
|
|
6
|
+
* tmMethodKey) reads it, and tm.js imports nothing but Node built-ins.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The plain LLM methods: one prompt (lib/methods/llm.js promptSettingsFor +
|
|
11
|
+
* buildSystemMessage + buildUserMessage), different transports. Their system
|
|
12
|
+
* message carries the pair's coaching text as a "Coaching guidance:" block.
|
|
13
|
+
*/
|
|
14
|
+
export const PLAIN_LLM_METHODS = new Set(['llm', 'openai', 'anthropic', 'gemini', 'local']);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every method whose prompt carries the pair's free-text coaching: the plain
|
|
18
|
+
* LLM methods, and llm-coached (which adds its structured coaching).
|
|
19
|
+
*/
|
|
20
|
+
export const COACHING_PROMPT_METHODS = new Set([...PLAIN_LLM_METHODS, 'llm-coached']);
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
EST_INPUT_TOKENS_PER_KEY,
|
|
22
22
|
EST_OUTPUT_TOKENS_PER_KEY,
|
|
23
23
|
} from '../config.js';
|
|
24
|
-
import { fetchModelPricing } from './openrouter-pricing.js';
|
|
24
|
+
import { fetchModelPricing, priceListFetchedAt, OPENROUTER_MODELS_URL } from './openrouter-pricing.js';
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Per-provider pricing data.
|
|
@@ -34,21 +34,29 @@ import { fetchModelPricing } from './openrouter-pricing.js';
|
|
|
34
34
|
export const PROVIDER_RATES = {
|
|
35
35
|
'google-translate': {
|
|
36
36
|
costPerMillionChars: 20,
|
|
37
|
+
verified: '2026-06-08',
|
|
38
|
+
url: 'https://cloud.google.com/translate/pricing',
|
|
37
39
|
source: 'google-cloud-pricing',
|
|
38
40
|
note: 'Google Cloud Translation API v2 ($20/1M chars).',
|
|
39
41
|
},
|
|
40
42
|
'deepl': {
|
|
41
43
|
costPerMillionChars: 25,
|
|
44
|
+
verified: '2026-06-08',
|
|
45
|
+
url: 'https://www.deepl.com/pro-api',
|
|
42
46
|
source: 'deepl-api-pro-pricing',
|
|
43
47
|
note: 'DeepL API Pro (~$25/1M chars). Free tier: 500K chars/month cap.',
|
|
44
48
|
},
|
|
45
49
|
'microsoft-translator': {
|
|
46
50
|
costPerMillionChars: 10,
|
|
51
|
+
verified: '2026-06-08',
|
|
52
|
+
url: 'https://azure.microsoft.com/pricing/details/cognitive-services/translator/',
|
|
47
53
|
source: 'microsoft-translator-pricing',
|
|
48
54
|
note: 'Azure Translator S1 ($10/1M chars). 2M chars/month free tier.',
|
|
49
55
|
},
|
|
50
56
|
'libretranslate': {
|
|
51
57
|
costPerMillionChars: 0,
|
|
58
|
+
verified: null,
|
|
59
|
+
url: null,
|
|
52
60
|
source: 'libretranslate-self-hosted',
|
|
53
61
|
note: 'Self-hosted, free. Infrastructure costs not included.',
|
|
54
62
|
},
|
|
@@ -206,6 +214,8 @@ export async function estimateLlmCost(provider, model, keyCount) {
|
|
|
206
214
|
let rate = null;
|
|
207
215
|
let source = null;
|
|
208
216
|
let divergence = '';
|
|
217
|
+
// Where the rate came from, for the estimate's `rate` (said in the table).
|
|
218
|
+
let from = null;
|
|
209
219
|
|
|
210
220
|
// The OpenRouter namespace must match the provider being billed. Without
|
|
211
221
|
// this, estimateLlmCost('anthropic', 'gpt-4o', …) would happily price the
|
|
@@ -229,6 +239,7 @@ export async function estimateLlmCost(provider, model, keyCount) {
|
|
|
229
239
|
// dollars per million tokens.
|
|
230
240
|
rate = { input: live.input * 1_000_000, output: live.output * 1_000_000 };
|
|
231
241
|
source = `openrouter-live (proxy for ${provider} list price)`;
|
|
242
|
+
from = { from: 'openrouter-price-list', url: OPENROUTER_MODELS_URL, fetchedAt: priceListFetchedAt(), proxyFor: provider };
|
|
232
243
|
|
|
233
244
|
if (pinnedUsable) {
|
|
234
245
|
const drift = (a, b) => (b === 0 ? (a === 0 ? 0 : 1) : Math.abs(a - b) / b);
|
|
@@ -254,6 +265,13 @@ export async function estimateLlmCost(provider, model, keyCount) {
|
|
|
254
265
|
if (!rate && pinnedUsable) {
|
|
255
266
|
rate = { input: pinned.input, output: pinned.output };
|
|
256
267
|
source = `pinned-table (${pinned.verified ? `verified ${pinned.verified}` : 'UNVERIFIED'}; live draw unavailable)`;
|
|
268
|
+
// Why the copy, not the live list: the draw is off, the list could not
|
|
269
|
+
// be read, or it was read and has no price for this model.
|
|
270
|
+
let liveDraw = 'unavailable';
|
|
271
|
+
if (pricingOffline()) liveDraw = 'off';
|
|
272
|
+
else if (!orId || !namespaceOk) liveDraw = 'not-listed';
|
|
273
|
+
else if (priceListFetchedAt()) liveDraw = 'no-price';
|
|
274
|
+
from = { from: 'pinned-table', verified: pinned.verified || null, liveDraw };
|
|
257
275
|
}
|
|
258
276
|
|
|
259
277
|
if (!rate) {
|
|
@@ -279,6 +297,16 @@ export async function estimateLlmCost(provider, model, keyCount) {
|
|
|
279
297
|
note: `Based on ${provider} ${model} pricing `
|
|
280
298
|
+ `($${rate.input.toFixed(2)}/1M input, $${rate.output.toFixed(2)}/1M output, `
|
|
281
299
|
+ `${source}).${divergence}`,
|
|
300
|
+
// The rate used, where it came from and when (cost-report.js says it in
|
|
301
|
+
// one line; --json carries this whole object).
|
|
302
|
+
rate: {
|
|
303
|
+
model,
|
|
304
|
+
unit: 'token',
|
|
305
|
+
inputPerMillion: Number(rate.input.toPrecision(12)),
|
|
306
|
+
outputPerMillion: Number(rate.output.toPrecision(12)),
|
|
307
|
+
tokensPerKey: { input: EST_INPUT_TOKENS_PER_KEY, output: EST_OUTPUT_TOKENS_PER_KEY },
|
|
308
|
+
...from,
|
|
309
|
+
},
|
|
282
310
|
};
|
|
283
311
|
}
|
|
284
312
|
|
|
@@ -306,5 +334,18 @@ export function estimateProviderCost(provider, keyCount) {
|
|
|
306
334
|
currency: 'USD',
|
|
307
335
|
source: rate.source,
|
|
308
336
|
note: rate.note,
|
|
337
|
+
// A published per-character rate, as this file records it (no pricing
|
|
338
|
+
// API exists for these providers): its date says how old the figure is.
|
|
339
|
+
...(rate.costPerMillionChars > 0 && {
|
|
340
|
+
rate: {
|
|
341
|
+
model: provider,
|
|
342
|
+
unit: 'char',
|
|
343
|
+
perMillionChars: rate.costPerMillionChars,
|
|
344
|
+
charsPerKey: EST_CHARS_PER_KEY,
|
|
345
|
+
from: 'published-rate',
|
|
346
|
+
url: rate.url,
|
|
347
|
+
verified: rate.verified,
|
|
348
|
+
},
|
|
349
|
+
}),
|
|
309
350
|
};
|
|
310
351
|
}
|