claude-translator 1.3.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/CHANGELOG.md +135 -0
- package/LICENSE +662 -0
- package/LICENSING.md +69 -0
- package/README.md +434 -0
- package/SKILL.md +206 -0
- package/bin/claude-translator.mjs +230 -0
- package/bin/cli.test.mjs +165 -0
- package/i18n.config.example.json +67 -0
- package/package.json +60 -0
- package/references/adapting-generators.md +76 -0
- package/references/failure-modes.md +255 -0
- package/references/providers.md +157 -0
- package/references/quality-review.md +91 -0
- package/references/throughput-and-cost.md +124 -0
- package/scripts/audit-seo.mjs +261 -0
- package/scripts/build-locales.mjs +336 -0
- package/scripts/config.mjs +188 -0
- package/scripts/credit.mjs +143 -0
- package/scripts/extract.mjs +564 -0
- package/scripts/finalize.sh +58 -0
- package/scripts/providers/anthropic.mjs +118 -0
- package/scripts/providers/gemini.mjs +72 -0
- package/scripts/providers/index.mjs +95 -0
- package/scripts/providers/openai.mjs +120 -0
- package/scripts/providers/providers.test.mjs +214 -0
- package/scripts/review.mjs +310 -0
- package/scripts/translate.mjs +455 -0
- package/scripts/verify.mjs +384 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic Claude — the default provider.
|
|
3
|
+
*
|
|
4
|
+
* ── Structured output ────────────────────────────────────────────────────────
|
|
5
|
+
* `output_config.format` with a JSON schema. Two constraints shape the schema:
|
|
6
|
+
* every object needs `additionalProperties: false`, and the documented form takes an
|
|
7
|
+
* object at the root — so the units are wrapped in `{ "translations": [...] }` and
|
|
8
|
+
* unwrapped here. (Assistant prefill, the old way of forcing a JSON array, returns a
|
|
9
|
+
* 400 on current models. It is not an option.)
|
|
10
|
+
*
|
|
11
|
+
* ── Why the capability table exists ──────────────────────────────────────────
|
|
12
|
+
* Claude models do not take the same parameters, and guessing wrong is not a silent
|
|
13
|
+
* no-op — it either errors or spends money:
|
|
14
|
+
*
|
|
15
|
+
* claude-haiku-4-5 `output_config.effort` is REJECTED. Thinking is off unless
|
|
16
|
+
* explicitly enabled. Send neither. This is the default model
|
|
17
|
+
* because bulk segment translation is a low-reasoning task and
|
|
18
|
+
* Haiku is the only tier priced for it.
|
|
19
|
+
* opus-5 / sonnet-5 Adaptive thinking; on Opus 5 it is ON BY DEFAULT. Left alone,
|
|
20
|
+
* opus-4-8 / fable-5 a large translation run would silently pay for reasoning it
|
|
21
|
+
* does not need, so these get `effort: 'low'`.
|
|
22
|
+
*
|
|
23
|
+
* Low effort is used rather than `thinking: {type:'disabled'}` because disabling has
|
|
24
|
+
* known failure modes on Opus 5 — it can write a tool call into visible text and leak
|
|
25
|
+
* thinking tags.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const API = 'https://api.anthropic.com/v1';
|
|
29
|
+
const VERSION_HEADER = '2023-06-01';
|
|
30
|
+
|
|
31
|
+
export const id = 'anthropic';
|
|
32
|
+
export const label = 'Anthropic Claude';
|
|
33
|
+
export const defaultModel = 'claude-haiku-4-5';
|
|
34
|
+
export const envKeys = ['ANTHROPIC_API_KEY'];
|
|
35
|
+
|
|
36
|
+
const RESPONSE_SCHEMA = {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
translations: {
|
|
40
|
+
type: 'array',
|
|
41
|
+
items: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: { id: { type: 'integer' }, text: { type: 'string' } },
|
|
44
|
+
required: ['id', 'text'],
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
required: ['translations'],
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Models that accept output_config.effort. Haiku 4.5 rejects it outright. */
|
|
54
|
+
const ACCEPTS_EFFORT = [/^claude-opus-/, /^claude-sonnet-5/, /^claude-fable-/, /^claude-mythos-/];
|
|
55
|
+
const acceptsEffort = (model) => ACCEPTS_EFFORT.some((re) => re.test(model));
|
|
56
|
+
|
|
57
|
+
export function request({ model, system, items, temperature, key, baseUrl }) {
|
|
58
|
+
const body = {
|
|
59
|
+
model,
|
|
60
|
+
max_tokens: 16000,
|
|
61
|
+
system,
|
|
62
|
+
messages: [{ role: 'user', content: JSON.stringify(items) }],
|
|
63
|
+
output_config: { format: { type: 'json_schema', schema: RESPONSE_SCHEMA } },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Keep the reasoning budget off the bill for a task that does not need it.
|
|
67
|
+
if (acceptsEffort(model)) body.output_config.effort = 'low';
|
|
68
|
+
else body.temperature = temperature; // Haiku-class models still take sampling params
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
url: `${(baseUrl ?? API).replace(/\/$/, '')}/messages`,
|
|
72
|
+
headers: {
|
|
73
|
+
'x-api-key': key,
|
|
74
|
+
'anthropic-version': VERSION_HEADER,
|
|
75
|
+
'content-type': 'application/json',
|
|
76
|
+
},
|
|
77
|
+
body,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function parse(data) {
|
|
82
|
+
const usage = data?.usage ?? {};
|
|
83
|
+
const norm = { inTok: usage.input_tokens ?? 0, outTok: usage.output_tokens ?? 0 };
|
|
84
|
+
|
|
85
|
+
// A safety decline arrives as HTTP 200 with stop_reason "refusal" — the analogue of
|
|
86
|
+
// Gemini's blockReason, and handled the same way: split the batch so the blast radius
|
|
87
|
+
// is the offending unit rather than all forty.
|
|
88
|
+
if (data?.stop_reason === 'refusal') {
|
|
89
|
+
return {
|
|
90
|
+
text: null,
|
|
91
|
+
usage: norm,
|
|
92
|
+
retryable: 'safety',
|
|
93
|
+
detail: data?.stop_details?.category ?? 'refusal',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const text = (data?.content ?? []).find((b) => b?.type === 'text')?.text;
|
|
98
|
+
if (!text) return { text: null, usage: norm, retryable: null, detail: data?.stop_reason };
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
text,
|
|
102
|
+
usage: norm,
|
|
103
|
+
retryable: data?.stop_reason === 'max_tokens' ? 'truncated' : null,
|
|
104
|
+
detail: data?.stop_reason,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The schema wraps the array in an object; the pipeline wants the array. */
|
|
109
|
+
export const unwrap = (parsed) => parsed?.translations ?? parsed;
|
|
110
|
+
|
|
111
|
+
/** USD per million tokens [input, output]. Checked 2026-08-25. */
|
|
112
|
+
export function pricing(model) {
|
|
113
|
+
if (model.startsWith('claude-haiku-4-5')) return [1, 5];
|
|
114
|
+
if (model.startsWith('claude-sonnet-5')) return [3, 15];
|
|
115
|
+
if (model.startsWith('claude-opus-')) return [5, 25];
|
|
116
|
+
if (model.startsWith('claude-fable-') || model.startsWith('claude-mythos-')) return [10, 50];
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Gemini.
|
|
3
|
+
*
|
|
4
|
+
* This adapter is a straight lift of the call that shipped in 1.0 and 1.1, and its
|
|
5
|
+
* behaviour must stay identical: it is the only path with a ten-thousand-unit
|
|
6
|
+
* production run behind it. In particular the response schema stays in Gemini's own
|
|
7
|
+
* uppercase type dialect, and the units come back as a bare top-level array — the
|
|
8
|
+
* other adapters wrap theirs in an object because their APIs require it, but changing
|
|
9
|
+
* this one would be a change for symmetry's sake against proven code.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const API = 'https://generativelanguage.googleapis.com/v1beta/models';
|
|
13
|
+
|
|
14
|
+
export const id = 'gemini';
|
|
15
|
+
export const label = 'Google Gemini';
|
|
16
|
+
export const defaultModel = 'gemini-2.5-flash-lite';
|
|
17
|
+
export const envKeys = ['GEMINI_API_KEY', 'GOOGLE_API_KEY'];
|
|
18
|
+
|
|
19
|
+
/** Gemini's schema dialect: uppercase type names, no additionalProperties. */
|
|
20
|
+
const RESPONSE_SCHEMA = {
|
|
21
|
+
type: 'ARRAY',
|
|
22
|
+
items: {
|
|
23
|
+
type: 'OBJECT',
|
|
24
|
+
properties: { id: { type: 'INTEGER' }, text: { type: 'STRING' } },
|
|
25
|
+
required: ['id', 'text'],
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function request({ model, system, items, temperature, key, baseUrl }) {
|
|
30
|
+
return {
|
|
31
|
+
url: `${(baseUrl ?? API).replace(/\/$/, '')}/${model}:generateContent`,
|
|
32
|
+
headers: { 'x-goog-api-key': key, 'content-type': 'application/json' },
|
|
33
|
+
body: {
|
|
34
|
+
systemInstruction: { parts: [{ text: system }] },
|
|
35
|
+
contents: [{ role: 'user', parts: [{ text: JSON.stringify(items) }] }],
|
|
36
|
+
generationConfig: {
|
|
37
|
+
temperature,
|
|
38
|
+
responseMimeType: 'application/json',
|
|
39
|
+
responseSchema: RESPONSE_SCHEMA,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parse(data) {
|
|
46
|
+
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
47
|
+
const usage = data?.usageMetadata ?? {};
|
|
48
|
+
const norm = { inTok: usage.promptTokenCount ?? 0, outTok: usage.candidatesTokenCount ?? 0 };
|
|
49
|
+
|
|
50
|
+
// The safety filter blocks the WHOLE request, so one string it dislikes takes the
|
|
51
|
+
// other 39 in the batch with it. Reported as 'safety' so the caller can split.
|
|
52
|
+
if (!text) {
|
|
53
|
+
const blocked = data?.promptFeedback?.blockReason;
|
|
54
|
+
if (blocked) return { text: null, usage: norm, retryable: 'safety', detail: blocked };
|
|
55
|
+
return { text: null, usage: norm, retryable: null };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const finish = data?.candidates?.[0]?.finishReason;
|
|
59
|
+
return {
|
|
60
|
+
text,
|
|
61
|
+
usage: norm,
|
|
62
|
+
retryable: finish === 'MAX_TOKENS' ? 'truncated' : null,
|
|
63
|
+
detail: finish,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** USD per million tokens [input, output]. Checked 2026-08-25. */
|
|
68
|
+
export function pricing(model) {
|
|
69
|
+
if (model.includes('flash-lite')) return [0.1, 0.4];
|
|
70
|
+
if (model.includes('flash')) return [0.3, 2.5];
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider resolution.
|
|
3
|
+
*
|
|
4
|
+
* Three built-ins, plus any local file: set `provider` to a path ending in `.mjs` and
|
|
5
|
+
* it is imported as-is. The interface is four exports — see references/providers.md.
|
|
6
|
+
*
|
|
7
|
+
* ── Why inference exists ─────────────────────────────────────────────────────
|
|
8
|
+
* 1.2 changed the default provider from Gemini to Claude. Configs written against 1.0
|
|
9
|
+
* and 1.1 pin `"model": "gemini-2.5-flash-lite"` and have no `provider` key, so a bare
|
|
10
|
+
* default would send a Gemini model id to Anthropic and fail with something unhelpful
|
|
11
|
+
* about an unknown model. Inferring the provider from the model id keeps every existing
|
|
12
|
+
* config working untouched; only a config with NEITHER key gets the new default.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { pathToFileURL } from 'url';
|
|
16
|
+
import { resolve } from 'path';
|
|
17
|
+
|
|
18
|
+
import * as gemini from './gemini.mjs';
|
|
19
|
+
import * as anthropic from './anthropic.mjs';
|
|
20
|
+
import * as openai from './openai.mjs';
|
|
21
|
+
|
|
22
|
+
export const BUILTIN = { gemini, anthropic, openai };
|
|
23
|
+
|
|
24
|
+
/** The provider used when a config names neither a provider nor a model. */
|
|
25
|
+
export const DEFAULT_PROVIDER = 'anthropic';
|
|
26
|
+
|
|
27
|
+
/** model id → provider, for configs that predate the `provider` key. */
|
|
28
|
+
const INFERENCE = [
|
|
29
|
+
[/^gemini[-.]/i, 'gemini'],
|
|
30
|
+
[/^(models\/)?gemini/i, 'gemini'],
|
|
31
|
+
[/^claude[-.]/i, 'anthropic'],
|
|
32
|
+
[/^(gpt|o[1-9]|chatgpt|text-davinci)/i, 'openai'],
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export function inferProvider(model) {
|
|
36
|
+
if (!model) return null;
|
|
37
|
+
for (const [re, id] of INFERENCE) if (re.test(model)) return id;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isPath = (name) => name.endsWith('.mjs') || name.includes('/');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a provider module from an explicit name, or infer one from the model.
|
|
45
|
+
* Returns the module; the caller supplies the model and key.
|
|
46
|
+
*/
|
|
47
|
+
export async function loadProvider({ provider, model, root }) {
|
|
48
|
+
const name = provider ?? inferProvider(model) ?? DEFAULT_PROVIDER;
|
|
49
|
+
|
|
50
|
+
if (isPath(name)) {
|
|
51
|
+
const abs = resolve(root ?? process.cwd(), name);
|
|
52
|
+
let mod;
|
|
53
|
+
try {
|
|
54
|
+
mod = await import(pathToFileURL(abs).href);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.error(`Could not load custom provider "${name}" (${abs}):\n ${err.message}`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
for (const fn of ['request', 'parse']) {
|
|
60
|
+
if (typeof mod[fn] !== 'function') {
|
|
61
|
+
console.error(`Custom provider "${name}" does not export ${fn}(). See references/providers.md.`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return mod;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const mod = BUILTIN[name];
|
|
69
|
+
if (!mod) {
|
|
70
|
+
console.error(
|
|
71
|
+
`Unknown provider "${name}". Built-ins: ${Object.keys(BUILTIN).join(', ')}.\n` +
|
|
72
|
+
`For anything else, point "provider" at a .mjs file — see references/providers.md.`
|
|
73
|
+
);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
return mod;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Strip a markdown code fence before parsing. Hosted models with a schema never need
|
|
81
|
+
* this; local and smaller models wrap JSON in ```json fences constantly, and that one
|
|
82
|
+
* habit accounts for most of the "it just fails on Ollama" reports.
|
|
83
|
+
*/
|
|
84
|
+
export function extractJson(text) {
|
|
85
|
+
const t = String(text).trim();
|
|
86
|
+
const fenced = /^```(?:json|JSON)?\s*\n([\s\S]*?)\n?```$/.exec(t);
|
|
87
|
+
if (fenced) return fenced[1].trim();
|
|
88
|
+
// Some models prepend a sentence before the JSON. Fall back to the outermost bracket.
|
|
89
|
+
if (!t.startsWith('{') && !t.startsWith('[')) {
|
|
90
|
+
const first = t.search(/[[{]/);
|
|
91
|
+
const last = Math.max(t.lastIndexOf(']'), t.lastIndexOf('}'));
|
|
92
|
+
if (first !== -1 && last > first) return t.slice(first, last + 1);
|
|
93
|
+
}
|
|
94
|
+
return t;
|
|
95
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Any OpenAI-compatible chat-completions endpoint.
|
|
3
|
+
*
|
|
4
|
+
* This is one adapter and a very long list of providers, because `/v1/chat/completions`
|
|
5
|
+
* is the de-facto interface: OpenAI, Azure OpenAI, Groq, DeepSeek, Mistral, OpenRouter,
|
|
6
|
+
* Together and Fireworks all speak it — and so do Ollama, LM Studio and vLLM, which is
|
|
7
|
+
* how this pipeline runs on a local model at no marginal cost:
|
|
8
|
+
*
|
|
9
|
+
* "provider": "openai",
|
|
10
|
+
* "baseUrl": "http://localhost:11434/v1",
|
|
11
|
+
* "model": "qwen2.5:14b"
|
|
12
|
+
*
|
|
13
|
+
* ── Structured output is a ladder, not a feature ─────────────────────────────
|
|
14
|
+
* Support varies wildly across that list, so the adapter asks for the strongest form
|
|
15
|
+
* the server advertises and degrades rather than failing:
|
|
16
|
+
*
|
|
17
|
+
* 1. `response_format: {type:'json_schema', …, strict:true}` — OpenAI, Azure, vLLM
|
|
18
|
+
* 2. `response_format: {type:'json_object'}` — most gateways, Ollama
|
|
19
|
+
* 3. nothing but the prompt — everything else
|
|
20
|
+
*
|
|
21
|
+
* `translate.mjs` already validates every unit's placeholders and retries what fails,
|
|
22
|
+
* so a weaker guarantee here costs retries, not correctness. The rung is chosen by
|
|
23
|
+
* config (`jsonMode`) and falls back automatically when the server rejects a request
|
|
24
|
+
* for mentioning `response_format`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const API = 'https://api.openai.com/v1';
|
|
28
|
+
|
|
29
|
+
export const id = 'openai';
|
|
30
|
+
export const label = 'OpenAI-compatible';
|
|
31
|
+
export const defaultModel = 'gpt-4o-mini';
|
|
32
|
+
export const envKeys = ['OPENAI_API_KEY', 'OPENAI_COMPATIBLE_API_KEY'];
|
|
33
|
+
|
|
34
|
+
/** Local servers accept any key, but some reject a missing Authorization header. */
|
|
35
|
+
export const keyOptional = true;
|
|
36
|
+
|
|
37
|
+
const RESPONSE_SCHEMA = {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
translations: {
|
|
41
|
+
type: 'array',
|
|
42
|
+
items: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: { id: { type: 'integer' }, text: { type: 'string' } },
|
|
45
|
+
required: ['id', 'text'],
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
required: ['translations'],
|
|
51
|
+
additionalProperties: false,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function request({ model, system, items, temperature, key, baseUrl, jsonMode }) {
|
|
55
|
+
const body = {
|
|
56
|
+
model,
|
|
57
|
+
temperature,
|
|
58
|
+
messages: [
|
|
59
|
+
{ role: 'system', content: system },
|
|
60
|
+
{ role: 'user', content: JSON.stringify(items) },
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const mode = jsonMode ?? 'schema';
|
|
65
|
+
if (mode === 'schema') {
|
|
66
|
+
body.response_format = {
|
|
67
|
+
type: 'json_schema',
|
|
68
|
+
json_schema: { name: 'translations', strict: true, schema: RESPONSE_SCHEMA },
|
|
69
|
+
};
|
|
70
|
+
} else if (mode === 'object') {
|
|
71
|
+
body.response_format = { type: 'json_object' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
url: `${(baseUrl ?? API).replace(/\/$/, '')}/chat/completions`,
|
|
76
|
+
headers: {
|
|
77
|
+
...(key ? { authorization: `Bearer ${key}` } : {}),
|
|
78
|
+
'content-type': 'application/json',
|
|
79
|
+
},
|
|
80
|
+
body,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function parse(data) {
|
|
85
|
+
const choice = data?.choices?.[0];
|
|
86
|
+
const usage = data?.usage ?? {};
|
|
87
|
+
const norm = { inTok: usage.prompt_tokens ?? 0, outTok: usage.completion_tokens ?? 0 };
|
|
88
|
+
|
|
89
|
+
const finish = choice?.finish_reason;
|
|
90
|
+
if (finish === 'content_filter') {
|
|
91
|
+
return { text: null, usage: norm, retryable: 'safety', detail: 'content_filter' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const text = choice?.message?.content;
|
|
95
|
+
if (!text) return { text: null, usage: norm, retryable: null, detail: finish };
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
text,
|
|
99
|
+
usage: norm,
|
|
100
|
+
retryable: finish === 'length' ? 'truncated' : null,
|
|
101
|
+
detail: finish,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const unwrap = (parsed) => parsed?.translations ?? parsed;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A rejection caused by response_format rather than by the request being wrong.
|
|
109
|
+
* Lets translate.mjs drop one rung of the ladder instead of failing the run — the
|
|
110
|
+
* difference between "this server is older than I assumed" and "this is broken".
|
|
111
|
+
*/
|
|
112
|
+
export function unsupportedJsonMode(status, errText) {
|
|
113
|
+
if (status !== 400 && status !== 404 && status !== 422) return false;
|
|
114
|
+
return /response_format|json_schema|json_object|not supported|unrecognized|unknown.*field/i.test(errText);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Unknown by design: this adapter points at dozens of providers, and local ones are free. */
|
|
118
|
+
export function pricing() {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract tests for the provider adapters.
|
|
3
|
+
*
|
|
4
|
+
* node --test scripts/providers/
|
|
5
|
+
*
|
|
6
|
+
* These assert the two things an adapter is responsible for: the exact shape of the
|
|
7
|
+
* request it builds, and what it makes of a response. They use no network and no key,
|
|
8
|
+
* so they run in CI and on a contributor's laptop.
|
|
9
|
+
*
|
|
10
|
+
* What they do NOT prove is that a remote server accepts the request — only a live call
|
|
11
|
+
* does that. What they DO catch is the class of bug that is otherwise invisible until
|
|
12
|
+
* someone spends money: a misspelled header, a field nested one level too deep, a usage
|
|
13
|
+
* counter read from the wrong key, a safety block that fails to trigger the batch split.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
|
|
19
|
+
import * as gemini from './gemini.mjs';
|
|
20
|
+
import * as anthropic from './anthropic.mjs';
|
|
21
|
+
import * as openai from './openai.mjs';
|
|
22
|
+
import { inferProvider, extractJson } from './index.mjs';
|
|
23
|
+
|
|
24
|
+
const ITEMS = [{ id: 0, text: 'With <0>Acme</0>, hello.' }];
|
|
25
|
+
const ARGS = { system: 'You are a translator.', items: ITEMS, temperature: 0.2, key: 'test-key' };
|
|
26
|
+
|
|
27
|
+
// ── Gemini ───────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
test('gemini: request targets generateContent with the key header', () => {
|
|
30
|
+
const r = gemini.request({ ...ARGS, model: 'gemini-2.5-flash-lite' });
|
|
31
|
+
assert.equal(r.url, 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent');
|
|
32
|
+
assert.equal(r.headers['x-goog-api-key'], 'test-key');
|
|
33
|
+
assert.equal(r.body.systemInstruction.parts[0].text, ARGS.system);
|
|
34
|
+
assert.equal(r.body.contents[0].parts[0].text, JSON.stringify(ITEMS));
|
|
35
|
+
assert.equal(r.body.generationConfig.responseMimeType, 'application/json');
|
|
36
|
+
// Gemini's own dialect: uppercase types, bare array at the root.
|
|
37
|
+
assert.equal(r.body.generationConfig.responseSchema.type, 'ARRAY');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('gemini: parses text and normalises usage', () => {
|
|
41
|
+
const out = gemini.parse({
|
|
42
|
+
candidates: [{ content: { parts: [{ text: '[]' }] }, finishReason: 'STOP' }],
|
|
43
|
+
usageMetadata: { promptTokenCount: 11, candidatesTokenCount: 22 },
|
|
44
|
+
});
|
|
45
|
+
assert.equal(out.text, '[]');
|
|
46
|
+
assert.deepEqual(out.usage, { inTok: 11, outTok: 22 });
|
|
47
|
+
assert.equal(out.retryable, null);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('gemini: a block reason asks for a split', () => {
|
|
51
|
+
const out = gemini.parse({ promptFeedback: { blockReason: 'PROHIBITED_CONTENT' } });
|
|
52
|
+
assert.equal(out.retryable, 'safety');
|
|
53
|
+
assert.equal(out.detail, 'PROHIBITED_CONTENT');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('gemini: MAX_TOKENS asks for a split', () => {
|
|
57
|
+
const out = gemini.parse({
|
|
58
|
+
candidates: [{ content: { parts: [{ text: '[{' }] }, finishReason: 'MAX_TOKENS' }],
|
|
59
|
+
usageMetadata: {},
|
|
60
|
+
});
|
|
61
|
+
assert.equal(out.retryable, 'truncated');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ── Anthropic ────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
test('anthropic: request matches the Messages API contract', () => {
|
|
67
|
+
const r = anthropic.request({ ...ARGS, model: 'claude-haiku-4-5' });
|
|
68
|
+
assert.equal(r.url, 'https://api.anthropic.com/v1/messages');
|
|
69
|
+
assert.equal(r.headers['x-api-key'], 'test-key');
|
|
70
|
+
assert.equal(r.headers['anthropic-version'], '2023-06-01');
|
|
71
|
+
assert.equal(r.body.model, 'claude-haiku-4-5');
|
|
72
|
+
assert.ok(r.body.max_tokens > 0, 'max_tokens is required by this API');
|
|
73
|
+
assert.equal(r.body.system, ARGS.system);
|
|
74
|
+
assert.equal(r.body.messages[0].role, 'user');
|
|
75
|
+
assert.equal(r.body.output_config.format.type, 'json_schema');
|
|
76
|
+
// Structured outputs need a root object and additionalProperties:false everywhere.
|
|
77
|
+
assert.equal(r.body.output_config.format.schema.type, 'object');
|
|
78
|
+
assert.equal(r.body.output_config.format.schema.additionalProperties, false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('anthropic: haiku gets no effort parameter (the API rejects it there)', () => {
|
|
82
|
+
const r = anthropic.request({ ...ARGS, model: 'claude-haiku-4-5' });
|
|
83
|
+
assert.equal(r.body.output_config.effort, undefined);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('anthropic: thinking-capable tiers get low effort so a bulk run is not billed for reasoning', () => {
|
|
87
|
+
for (const model of ['claude-opus-5', 'claude-sonnet-5', 'claude-opus-4-8']) {
|
|
88
|
+
const r = anthropic.request({ ...ARGS, model });
|
|
89
|
+
assert.equal(r.body.output_config.effort, 'low', `${model} should pin low effort`);
|
|
90
|
+
assert.equal(r.body.temperature, undefined, `${model} does not accept sampling params`);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('anthropic: reads text and usage, and unwraps the schema envelope', () => {
|
|
95
|
+
const out = anthropic.parse({
|
|
96
|
+
content: [{ type: 'text', text: '{"translations":[]}' }],
|
|
97
|
+
stop_reason: 'end_turn',
|
|
98
|
+
usage: { input_tokens: 7, output_tokens: 9 },
|
|
99
|
+
});
|
|
100
|
+
assert.equal(out.text, '{"translations":[]}');
|
|
101
|
+
assert.deepEqual(out.usage, { inTok: 7, outTok: 9 });
|
|
102
|
+
assert.deepEqual(anthropic.unwrap({ translations: [1, 2] }), [1, 2]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('anthropic: a refusal asks for a split, like a Gemini block', () => {
|
|
106
|
+
const out = anthropic.parse({
|
|
107
|
+
stop_reason: 'refusal',
|
|
108
|
+
stop_details: { type: 'refusal', category: 'cyber' },
|
|
109
|
+
usage: { input_tokens: 1, output_tokens: 0 },
|
|
110
|
+
});
|
|
111
|
+
assert.equal(out.retryable, 'safety');
|
|
112
|
+
assert.equal(out.detail, 'cyber');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('anthropic: max_tokens asks for a split', () => {
|
|
116
|
+
const out = anthropic.parse({
|
|
117
|
+
content: [{ type: 'text', text: '{"translations":[{' }],
|
|
118
|
+
stop_reason: 'max_tokens',
|
|
119
|
+
usage: {},
|
|
120
|
+
});
|
|
121
|
+
assert.equal(out.retryable, 'truncated');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── OpenAI-compatible ────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
test('openai: default host and bearer auth', () => {
|
|
127
|
+
const r = openai.request({ ...ARGS, model: 'gpt-4o-mini' });
|
|
128
|
+
assert.equal(r.url, 'https://api.openai.com/v1/chat/completions');
|
|
129
|
+
assert.equal(r.headers.authorization, 'Bearer test-key');
|
|
130
|
+
assert.equal(r.body.messages[0].role, 'system');
|
|
131
|
+
assert.equal(r.body.messages[1].content, JSON.stringify(ITEMS));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('openai: a custom host is used verbatim — this is how local models work', () => {
|
|
135
|
+
const r = openai.request({ ...ARGS, model: 'qwen2.5:14b', baseUrl: 'http://localhost:11434/v1' });
|
|
136
|
+
assert.equal(r.url, 'http://localhost:11434/v1/chat/completions');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('openai: a local server needs no key, and no Authorization header is sent', () => {
|
|
140
|
+
const r = openai.request({ ...ARGS, key: null, baseUrl: 'http://localhost:11434/v1', model: 'x' });
|
|
141
|
+
assert.equal(r.headers.authorization, undefined);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('openai: the json-mode ladder produces three distinct requests', () => {
|
|
145
|
+
const schema = openai.request({ ...ARGS, model: 'x', jsonMode: 'schema' });
|
|
146
|
+
assert.equal(schema.body.response_format.type, 'json_schema');
|
|
147
|
+
assert.equal(schema.body.response_format.json_schema.strict, true);
|
|
148
|
+
|
|
149
|
+
const object = openai.request({ ...ARGS, model: 'x', jsonMode: 'object' });
|
|
150
|
+
assert.equal(object.body.response_format.type, 'json_object');
|
|
151
|
+
|
|
152
|
+
const none = openai.request({ ...ARGS, model: 'x', jsonMode: 'none' });
|
|
153
|
+
assert.equal(none.body.response_format, undefined);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('openai: parses choices and normalises usage', () => {
|
|
157
|
+
const out = openai.parse({
|
|
158
|
+
choices: [{ message: { content: '{"translations":[]}' }, finish_reason: 'stop' }],
|
|
159
|
+
usage: { prompt_tokens: 3, completion_tokens: 4 },
|
|
160
|
+
});
|
|
161
|
+
assert.equal(out.text, '{"translations":[]}');
|
|
162
|
+
assert.deepEqual(out.usage, { inTok: 3, outTok: 4 });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('openai: length and content_filter map to the split reasons', () => {
|
|
166
|
+
assert.equal(
|
|
167
|
+
openai.parse({ choices: [{ message: { content: 'x' }, finish_reason: 'length' }] }).retryable,
|
|
168
|
+
'truncated'
|
|
169
|
+
);
|
|
170
|
+
assert.equal(
|
|
171
|
+
openai.parse({ choices: [{ finish_reason: 'content_filter' }] }).retryable,
|
|
172
|
+
'safety'
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('openai: a response_format rejection is recognised as a capability gap, not a failure', () => {
|
|
177
|
+
assert.equal(openai.unsupportedJsonMode(400, "Unknown field 'response_format'"), true);
|
|
178
|
+
assert.equal(openai.unsupportedJsonMode(400, 'json_schema is not supported'), true);
|
|
179
|
+
// A genuine bad request must NOT be mistaken for one.
|
|
180
|
+
assert.equal(openai.unsupportedJsonMode(400, 'model not found'), false);
|
|
181
|
+
assert.equal(openai.unsupportedJsonMode(401, 'invalid api key'), false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ── Resolution and parsing helpers ───────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
test('provider is inferred from the model id, so pre-1.2 configs keep working', () => {
|
|
187
|
+
assert.equal(inferProvider('gemini-2.5-flash-lite'), 'gemini');
|
|
188
|
+
assert.equal(inferProvider('claude-haiku-4-5'), 'anthropic');
|
|
189
|
+
assert.equal(inferProvider('gpt-4o-mini'), 'openai');
|
|
190
|
+
assert.equal(inferProvider('qwen2.5:14b'), null, 'unknown ids must not guess');
|
|
191
|
+
assert.equal(inferProvider(null), null);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('extractJson survives what small and local models actually emit', () => {
|
|
195
|
+
const want = [{ id: 0, text: 'hi' }];
|
|
196
|
+
for (const raw of [
|
|
197
|
+
'[{"id":0,"text":"hi"}]',
|
|
198
|
+
'```json\n[{"id":0,"text":"hi"}]\n```',
|
|
199
|
+
'```\n[{"id":0,"text":"hi"}]\n```',
|
|
200
|
+
'Sure! Here you go:\n[{"id":0,"text":"hi"}]',
|
|
201
|
+
]) {
|
|
202
|
+
assert.deepEqual(JSON.parse(extractJson(raw)), want, `failed on: ${raw.slice(0, 30)}`);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('every built-in adapter satisfies the interface', async () => {
|
|
207
|
+
for (const mod of [gemini, anthropic, openai]) {
|
|
208
|
+
assert.equal(typeof mod.id, 'string');
|
|
209
|
+
assert.equal(typeof mod.defaultModel, 'string');
|
|
210
|
+
assert.ok(Array.isArray(mod.envKeys));
|
|
211
|
+
assert.equal(typeof mod.request, 'function');
|
|
212
|
+
assert.equal(typeof mod.parse, 'function');
|
|
213
|
+
}
|
|
214
|
+
});
|