claude-translator 1.3.0 → 1.4.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 +69 -0
- package/README.md +39 -6
- package/SKILL.md +1 -1
- package/package.json +1 -1
- package/references/providers.md +16 -2
- package/scripts/credit.mjs +1 -1
- package/scripts/providers/openai.mjs +63 -3
- package/scripts/providers/providers.test.mjs +80 -0
- package/scripts/translate.mjs +17 -6
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,49 @@ All notable changes to this project are documented here.
|
|
|
5
5
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.4.0] — 2026-08-25
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **The `openai` adapter's default model is now `gpt-5.6-luna`**, replacing `gpt-4o-mini`.
|
|
13
|
+
OpenAI has not published a shutdown date for `gpt-4o-mini`, but the rest of that generation
|
|
14
|
+
has one — `gpt-4o-2024-05-13`, `o1`, `o3-mini` and `gpt-4.1-nano` all retire 2026-10-23 — and
|
|
15
|
+
a 2024 model is no longer a sensible default for new projects. Anyone pinning `gpt-4o-mini` in
|
|
16
|
+
`i18n.config.json` is unaffected
|
|
17
|
+
|
|
18
|
+
- **Reasoning models no longer receive `temperature`.** This was a required change, not a
|
|
19
|
+
cosmetic one: `gpt-5.6-luna` rejects any temperature but `1`, so the model swap alone would
|
|
20
|
+
have returned `400 Unsupported value: 'temperature' does not support 0.2 with this model` on
|
|
21
|
+
every single request. Model ids matching `^gpt-5` or `^o[1-9]` now get no sampling parameter
|
|
22
|
+
and `reasoning_effort: 'none'` instead — bulk segment translation is a low-reasoning task, the
|
|
23
|
+
same argument `anthropic.mjs` makes when it pins its thinking tiers to `effort: 'low'`.
|
|
24
|
+
The match is anchored, so a local model whose id merely contains `gpt-5` keeps `temperature`
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **Automatic recovery from an unsupported request parameter.** A new optional adapter hook,
|
|
29
|
+
`unsupportedParam(status, errText)`, names one parameter the server rejected; `translate.mjs`
|
|
30
|
+
drops it and retries, exactly as it already steps down the `response_format` ladder. Each
|
|
31
|
+
parameter is dropped at most once, so it cannot loop. This is the general form of the bug
|
|
32
|
+
above — the next reasoning model with a new restriction now degrades instead of failing a run
|
|
33
|
+
|
|
34
|
+
The hook is deliberately hard to trigger: the error must read as a capability complaint *and*
|
|
35
|
+
name a parameter the adapter actually sends. A plain `400 model not found` is still a failure,
|
|
36
|
+
because an adapter that quietly strips its own request is worse than one that stops
|
|
37
|
+
|
|
38
|
+
- Seven contract tests covering the reasoning-model rule, the `gpt-4o-mini` regression, local
|
|
39
|
+
model ids, the `drop` set, and `unsupportedParam` — including that it and `unsupportedJsonMode`
|
|
40
|
+
never both claim the same error. 39 tests total
|
|
41
|
+
|
|
42
|
+
### Verified
|
|
43
|
+
|
|
44
|
+
- `gpt-5.6-luna` translated the same 98-unit site into Russian live: 3,450 / 2,876 tokens,
|
|
45
|
+
**0 failed units**, six of six gates, 0 SEO findings, and no fallback needed — the proactive
|
|
46
|
+
rule was correct up front. Its output shares only 21% of units with the `gpt-4o-mini` run,
|
|
47
|
+
confirming the new default actually took effect. `unsupportedParam` was separately checked
|
|
48
|
+
against a real 400 from the live API rather than a paraphrase of one. The Anthropic and Gemini
|
|
49
|
+
adapters were re-run unchanged to confirm the `translate.mjs` change disturbed neither
|
|
50
|
+
|
|
8
51
|
## [1.3.0] — 2026-08-25
|
|
9
52
|
|
|
10
53
|
### Added
|
|
@@ -28,6 +71,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
28
71
|
- `package.json` gained a `files` allowlist, so the tarball carries the scripts,
|
|
29
72
|
references, `SKILL.md` and the example config, and nothing else
|
|
30
73
|
|
|
74
|
+
### Verified
|
|
75
|
+
|
|
76
|
+
- **All three provider adapters called for real, for the first time** (2026-08-25). Until now
|
|
77
|
+
every adapter had only ever been exercised by the offline contract tests in
|
|
78
|
+
`scripts/providers/providers.test.mjs` — which prove the request we *build*, and cannot prove
|
|
79
|
+
the request a server *accepts*. Each adapter translated the same 98-unit, 1,381-word site into
|
|
80
|
+
Russian at its shipped default model, with `i18n/tm/ru.json` deleted between runs so that no
|
|
81
|
+
provider could silently reuse another's translations and report a success without calling
|
|
82
|
+
anything:
|
|
83
|
+
|
|
84
|
+
| Adapter | Model | Tokens in/out | Cost | Failed units | Gates | SEO audit |
|
|
85
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
86
|
+
| `anthropic` | `claude-haiku-4-5` | 4,315 / 4,130 | $0.025 | 0 | 6/6 | 0 findings |
|
|
87
|
+
| `gemini` | `gemini-2.5-flash-lite` | 3,412 / 3,611 | $0.002 | 0 | 6/6 | 0 findings |
|
|
88
|
+
| `openai` | `gpt-4o-mini` | 3,453 / 2,855 | not priced by design | 0 | 6/6 | 0 findings |
|
|
89
|
+
|
|
90
|
+
All three passed on the first attempt and no adapter needed changing. Specifically confirmed
|
|
91
|
+
live: Anthropic's `output_config.format` structured-output shape is accepted and Haiku 4.5
|
|
92
|
+
correctly receives `temperature` and no `effort`; both non-default model ids still exist; and
|
|
93
|
+
Cyrillic round-trips with every `<0>…</0>` placeholder intact across the 12 units that carry
|
|
94
|
+
inline markup, with the `doNotTranslate` glossary holding the brand name in all 10 units
|
|
95
|
+
containing it.
|
|
96
|
+
|
|
97
|
+
The README's provider table now carries a *last verified* date per adapter, so a retired model
|
|
98
|
+
id shows up as staleness rather than as a first-time user's unexplained 404.
|
|
99
|
+
|
|
31
100
|
## [1.2.0] — 2026-08-25
|
|
32
101
|
|
|
33
102
|
Renamed to **Claude Translator**, and the translation step is no longer tied to one vendor.
|
package/README.md
CHANGED
|
@@ -276,16 +276,49 @@ purging is destructive and its heuristics have known blind spots.
|
|
|
276
276
|
The translation step is the only part that talks to a model, and it talks through a small
|
|
277
277
|
adapter. Three ship with the project, and anything else is one file.
|
|
278
278
|
|
|
279
|
-
| `provider` | Default model | Key |
|
|
280
|
-
| --- | --- | --- |
|
|
281
|
-
| `anthropic` *(default)* | `claude-haiku-4-5` | `ANTHROPIC_API_KEY` |
|
|
282
|
-
| `gemini` | `gemini-2.5-flash-lite` | `GEMINI_API_KEY` |
|
|
283
|
-
| `openai` | `gpt-
|
|
284
|
-
| `./my-provider.mjs` | — | yours |
|
|
279
|
+
| `provider` | Default model | Key | Last verified against the live API |
|
|
280
|
+
| --- | --- | --- | --- |
|
|
281
|
+
| `anthropic` *(default)* | `claude-haiku-4-5` | `ANTHROPIC_API_KEY` | 2026-08-25 |
|
|
282
|
+
| `gemini` | `gemini-2.5-flash-lite` | `GEMINI_API_KEY` | 2026-08-25 |
|
|
283
|
+
| `openai` | `gpt-5.6-luna` | `OPENAI_API_KEY` | 2026-08-25 |
|
|
284
|
+
| `./my-provider.mjs` | — | yours | — |
|
|
285
285
|
|
|
286
286
|
Omit `provider` and it is inferred from the model id, so configs written before 1.2 keep
|
|
287
287
|
working unchanged.
|
|
288
288
|
|
|
289
|
+
**What "verified" means in that last column.** On 2026-08-25 each of the three adapters
|
|
290
|
+
translated the same 98-unit, 1,381-word English site into Russian at its default model, as a
|
|
291
|
+
real billed API call, with the translation memory deleted between runs so no provider could
|
|
292
|
+
reuse another's work. Every run cleared all six gates in `verify.mjs` and produced a clean
|
|
293
|
+
`audit-seo.mjs` — 0 findings — and no run had a single failed unit. Measured cost: $0.025
|
|
294
|
+
(Claude), $0.002 (Gemini), and 3,453/2,855 tokens on OpenAI, which the adapter deliberately
|
|
295
|
+
does not price because it points at dozens of endpoints, some of them free and local.
|
|
296
|
+
|
|
297
|
+
The three memories agreed on only 28–36% of units pairwise, and the ones all three agreed on
|
|
298
|
+
were short labels like "Три плана". That divergence is the evidence the runs were independent.
|
|
299
|
+
|
|
300
|
+
This column is a freshness marker, not a guarantee. Model ids get retired; if a default stops
|
|
301
|
+
working, that is what this date is for. The contract tests in `scripts/providers/` still run
|
|
302
|
+
on every commit with no network and no key — they catch a malformed request, not a rejected
|
|
303
|
+
one.
|
|
304
|
+
|
|
305
|
+
**Reasoning models and `temperature`.** The `openai` default is a reasoning model, and those
|
|
306
|
+
reject sampling parameters — GPT-5.x answers a `temperature: 0.2` with
|
|
307
|
+
`400 Unsupported value: 'temperature' … Only the default (1) value is supported`. The adapter
|
|
308
|
+
handles this twice over: it omits `temperature` and sends `reasoning_effort: 'none'` for model
|
|
309
|
+
ids it recognises as reasoning models, and if a server rejects a parameter anyway, the run drops
|
|
310
|
+
that one parameter and retries instead of failing. A pinned `gpt-4o-mini`, and every local model,
|
|
311
|
+
still get `temperature` exactly as before.
|
|
312
|
+
|
|
313
|
+
`reasoning_effort` is `'none'` because bulk segment translation is a low-reasoning task — the
|
|
314
|
+
same reason the Anthropic adapter pins its thinking tiers to `effort: 'low'`. Paying for a
|
|
315
|
+
reasoning pass on every batch of forty segments is the one cost here worth engineering away.
|
|
316
|
+
|
|
317
|
+
**Pricing.** `gpt-5.6-luna` is $0.20 in / $1.20 out per million tokens. The adapter reports no
|
|
318
|
+
cost, deliberately — `pricing()` cannot see `baseUrl`, so it cannot tell OpenAI itself from
|
|
319
|
+
OpenRouter or a local server offering the same model id. Set `pricing` in `i18n.config.json` to
|
|
320
|
+
get a figure in the run summary.
|
|
321
|
+
|
|
289
322
|
**The `openai` adapter is the interesting one**, because `/v1/chat/completions` is what
|
|
290
323
|
everything speaks. That one adapter covers OpenAI, Azure, Groq, DeepSeek, Mistral,
|
|
291
324
|
OpenRouter, Together and Fireworks — and Ollama, LM Studio and vLLM, which means the whole
|
package/SKILL.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-translator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Translate a static website into dozens of languages as real static pages, by substituting into built HTML instead of re-rendering.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
package/references/providers.md
CHANGED
|
@@ -18,7 +18,7 @@ talks through a small adapter. Three ship with the project; anything else is one
|
|
|
18
18
|
| --- | --- | --- | --- |
|
|
19
19
|
| `anthropic` | `claude-haiku-4-5` | `ANTHROPIC_API_KEY` | The default. |
|
|
20
20
|
| `gemini` | `gemini-2.5-flash-lite` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | Roughly a tenth the cost — see [throughput-and-cost.md](throughput-and-cost.md). |
|
|
21
|
-
| `openai` | `gpt-
|
|
21
|
+
| `openai` | `gpt-5.6-luna` | `OPENAI_API_KEY` | Any OpenAI-compatible endpoint, including local ones. |
|
|
22
22
|
| `./my-provider.mjs` | — | yours | A path is imported directly. See below. |
|
|
23
23
|
|
|
24
24
|
`--provider` and `--model` on the command line override the config for one run.
|
|
@@ -98,7 +98,7 @@ export const envKeys = ['MY_PROVIDER_KEY']; // checked in order, env then .env
|
|
|
98
98
|
export const keyOptional = false; // true for local servers
|
|
99
99
|
|
|
100
100
|
/** Build one HTTP request. */
|
|
101
|
-
export function request({ model, system, items, temperature, key, baseUrl, jsonMode }) {
|
|
101
|
+
export function request({ model, system, items, temperature, key, baseUrl, jsonMode, drop }) {
|
|
102
102
|
return {
|
|
103
103
|
url: `${baseUrl ?? 'https://api.example.com/v1'}/translate`,
|
|
104
104
|
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
@@ -121,6 +121,20 @@ export const unwrap = (parsed) => parsed.translations ?? parsed;
|
|
|
121
121
|
|
|
122
122
|
/** Optional: USD per million tokens, [in, out]. Omit if unknown. */
|
|
123
123
|
export function pricing(model) { return [0.5, 1.5]; }
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Optional: name ONE request parameter the server rejected, so translate.mjs can retry
|
|
127
|
+
* without it. Return null for anything else. The parameter is added to a `drop` set
|
|
128
|
+
* that is passed back into request(), and each one is dropped at most once.
|
|
129
|
+
*
|
|
130
|
+
* Set the bar high. The error must read as a capability complaint AND name a parameter
|
|
131
|
+
* you actually send; otherwise a genuine 400 gets mistaken for a recoverable one and the
|
|
132
|
+
* run quietly strips its own request instead of telling the user what is wrong.
|
|
133
|
+
*/
|
|
134
|
+
export function unsupportedParam(status, errText) {
|
|
135
|
+
if (status !== 400) return null;
|
|
136
|
+
return /unsupported.*\btemperature\b/i.test(errText) ? 'temperature' : null;
|
|
137
|
+
}
|
|
124
138
|
```
|
|
125
139
|
|
|
126
140
|
Then:
|
package/scripts/credit.mjs
CHANGED
|
@@ -22,18 +22,44 @@
|
|
|
22
22
|
* so a weaker guarantee here costs retries, not correctness. The rung is chosen by
|
|
23
23
|
* config (`jsonMode`) and falls back automatically when the server rejects a request
|
|
24
24
|
* for mentioning `response_format`.
|
|
25
|
+
*
|
|
26
|
+
* ── Sampling is not universal either ─────────────────────────────────────────
|
|
27
|
+
* The same "one adapter, many servers" problem applies to `temperature`. Reasoning
|
|
28
|
+
* models reject it outright — GPT-5.x answers a `temperature: 0.2` with
|
|
29
|
+
* 400 "Unsupported value: 'temperature' … Only the default (1) is supported" — and
|
|
30
|
+
* they take a reasoning budget instead. That is handled twice over, deliberately:
|
|
31
|
+
* proactively by the model table below, and reactively by `unsupportedParam()`, so a
|
|
32
|
+
* model family nobody has heard of yet degrades instead of failing the whole run.
|
|
25
33
|
*/
|
|
26
34
|
|
|
27
35
|
const API = 'https://api.openai.com/v1';
|
|
28
36
|
|
|
29
37
|
export const id = 'openai';
|
|
30
38
|
export const label = 'OpenAI-compatible';
|
|
31
|
-
export const defaultModel = 'gpt-
|
|
39
|
+
export const defaultModel = 'gpt-5.6-luna';
|
|
32
40
|
export const envKeys = ['OPENAI_API_KEY', 'OPENAI_COMPATIBLE_API_KEY'];
|
|
33
41
|
|
|
34
42
|
/** Local servers accept any key, but some reject a missing Authorization header. */
|
|
35
43
|
export const keyOptional = true;
|
|
36
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Model families that reject sampling parameters and take `reasoning_effort` instead.
|
|
47
|
+
*
|
|
48
|
+
* Kept to a prefix match on purpose. This adapter points at dozens of servers, and a
|
|
49
|
+
* local model whose id merely CONTAINS "gpt-5" must not be caught by it — `qwen2.5:14b`
|
|
50
|
+
* and friends still get `temperature` exactly as before.
|
|
51
|
+
*/
|
|
52
|
+
const REASONING = [/^gpt-5/i, /^o[1-9](-|$)/i];
|
|
53
|
+
const isReasoning = (model) => REASONING.some((re) => re.test(String(model)));
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parameters this adapter is willing to drop and retry without. Nothing else is
|
|
57
|
+
* droppable, so a 400 that happens to contain the word "unsupported" can never make the
|
|
58
|
+
* pipeline silently discard something load-bearing. `response_format` is deliberately
|
|
59
|
+
* absent — it has its own ladder in `unsupportedJsonMode()`.
|
|
60
|
+
*/
|
|
61
|
+
const DROPPABLE = ['temperature', 'reasoning_effort', 'top_p'];
|
|
62
|
+
|
|
37
63
|
const RESPONSE_SCHEMA = {
|
|
38
64
|
type: 'object',
|
|
39
65
|
properties: {
|
|
@@ -51,16 +77,28 @@ const RESPONSE_SCHEMA = {
|
|
|
51
77
|
additionalProperties: false,
|
|
52
78
|
};
|
|
53
79
|
|
|
54
|
-
export function request({ model, system, items, temperature, key, baseUrl, jsonMode }) {
|
|
80
|
+
export function request({ model, system, items, temperature, key, baseUrl, jsonMode, drop }) {
|
|
81
|
+
const dropped = drop ?? new Set();
|
|
55
82
|
const body = {
|
|
56
83
|
model,
|
|
57
|
-
temperature,
|
|
58
84
|
messages: [
|
|
59
85
|
{ role: 'system', content: system },
|
|
60
86
|
{ role: 'user', content: JSON.stringify(items) },
|
|
61
87
|
],
|
|
62
88
|
};
|
|
63
89
|
|
|
90
|
+
// Reasoning models: no sampling parameter, and no reasoning budget either. Bulk
|
|
91
|
+
// segment translation is a low-reasoning task, so paying for thinking tokens on every
|
|
92
|
+
// batch of forty is pure waste — the same call `anthropic.mjs` makes when it pins the
|
|
93
|
+
// thinking tiers to `effort: 'low'`. `temperature` is omitted rather than sent as 1 so
|
|
94
|
+
// the server applies its own default; sending a value it merely tolerates would be a
|
|
95
|
+
// claim we have no reason to make.
|
|
96
|
+
if (isReasoning(model)) {
|
|
97
|
+
if (!dropped.has('reasoning_effort')) body.reasoning_effort = 'none';
|
|
98
|
+
} else if (!dropped.has('temperature')) {
|
|
99
|
+
body.temperature = temperature;
|
|
100
|
+
}
|
|
101
|
+
|
|
64
102
|
const mode = jsonMode ?? 'schema';
|
|
65
103
|
if (mode === 'schema') {
|
|
66
104
|
body.response_format = {
|
|
@@ -111,9 +149,31 @@ export const unwrap = (parsed) => parsed?.translations ?? parsed;
|
|
|
111
149
|
*/
|
|
112
150
|
export function unsupportedJsonMode(status, errText) {
|
|
113
151
|
if (status !== 400 && status !== 404 && status !== 422) return false;
|
|
152
|
+
if (unsupportedParam(status, errText)) return false; // a sampling complaint, not a schema one
|
|
114
153
|
return /response_format|json_schema|json_object|not supported|unrecognized|unknown.*field/i.test(errText);
|
|
115
154
|
}
|
|
116
155
|
|
|
156
|
+
/**
|
|
157
|
+
* A rejection caused by ONE request parameter rather than by the request being wrong.
|
|
158
|
+
* Returns the parameter's name so `translate.mjs` can retry without it, or null.
|
|
159
|
+
*
|
|
160
|
+
* The bar is deliberately high: the error must read like a capability complaint AND name
|
|
161
|
+
* a parameter this adapter actually sends and is willing to lose. Anything looser would
|
|
162
|
+
* let a genuine 400 ("model not found") be mistaken for a recoverable one, and the run
|
|
163
|
+
* would keep quietly degrading its own request instead of telling the user.
|
|
164
|
+
*
|
|
165
|
+
* Real messages this must catch:
|
|
166
|
+
* 400 "Unsupported value: 'temperature' does not support 0.2 with this model.
|
|
167
|
+
* Only the default (1) is supported."
|
|
168
|
+
* 400 "Unrecognized request argument supplied: reasoning_effort"
|
|
169
|
+
*/
|
|
170
|
+
export function unsupportedParam(status, errText) {
|
|
171
|
+
if (status !== 400 && status !== 422) return null;
|
|
172
|
+
const text = String(errText ?? '');
|
|
173
|
+
if (!/unsupported|unrecognized|unknown|not supported|does not support/i.test(text)) return null;
|
|
174
|
+
return DROPPABLE.find((p) => new RegExp(`\\b${p}\\b`).test(text)) ?? null;
|
|
175
|
+
}
|
|
176
|
+
|
|
117
177
|
/** Unknown by design: this adapter points at dozens of providers, and local ones are free. */
|
|
118
178
|
export function pricing() {
|
|
119
179
|
return null;
|
|
@@ -131,6 +131,86 @@ test('openai: default host and bearer auth', () => {
|
|
|
131
131
|
assert.equal(r.body.messages[1].content, JSON.stringify(ITEMS));
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
// ── Reasoning models reject sampling ─────────────────────────────────────────
|
|
135
|
+
// GPT-5.x answers `temperature: 0.2` with a 400 and takes reasoning_effort instead.
|
|
136
|
+
// Both halves are asserted: what the reasoning models get, and — just as important —
|
|
137
|
+
// that nothing else changed for the models and local servers that were working.
|
|
138
|
+
|
|
139
|
+
test('openai: a reasoning model gets no temperature and no reasoning budget', () => {
|
|
140
|
+
const r = openai.request({ ...ARGS, model: 'gpt-5.6-luna' });
|
|
141
|
+
assert.equal(r.body.temperature, undefined, 'GPT-5.x returns 400 on any temperature but 1');
|
|
142
|
+
assert.equal(r.body.reasoning_effort, 'none', 'bulk translation must not pay for reasoning');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('openai: the o-series is treated the same way', () => {
|
|
146
|
+
for (const model of ['o1', 'o3-mini', 'o4-mini']) {
|
|
147
|
+
const r = openai.request({ ...ARGS, model });
|
|
148
|
+
assert.equal(r.body.temperature, undefined, `${model} rejects sampling params`);
|
|
149
|
+
assert.equal(r.body.reasoning_effort, 'none');
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('openai: a pinned gpt-4o-mini still gets temperature, unchanged', () => {
|
|
154
|
+
const r = openai.request({ ...ARGS, model: 'gpt-4o-mini' });
|
|
155
|
+
assert.equal(r.body.temperature, 0.2);
|
|
156
|
+
assert.equal(r.body.reasoning_effort, undefined);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('openai: a local model is never caught by the reasoning rule', () => {
|
|
160
|
+
// The match is anchored, so an id that merely CONTAINS "gpt-5" must not match —
|
|
161
|
+
// a local server would reject reasoning_effort and lose its sampling parameter.
|
|
162
|
+
for (const model of ['qwen2.5:14b', 'llama3.1:70b', 'my-finetune-of-gpt-5']) {
|
|
163
|
+
const r = openai.request({ ...ARGS, model });
|
|
164
|
+
assert.equal(r.body.temperature, 0.2, `${model} must keep sampling`);
|
|
165
|
+
assert.equal(r.body.reasoning_effort, undefined, `${model} must not be sent a reasoning budget`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('openai: request honours an explicit drop set', () => {
|
|
170
|
+
const noTemp = openai.request({ ...ARGS, model: 'gpt-4o-mini', drop: new Set(['temperature']) });
|
|
171
|
+
assert.equal(noTemp.body.temperature, undefined);
|
|
172
|
+
|
|
173
|
+
const noEffort = openai.request({ ...ARGS, model: 'gpt-5.6-luna', drop: new Set(['reasoning_effort']) });
|
|
174
|
+
assert.equal(noEffort.body.reasoning_effort, undefined);
|
|
175
|
+
assert.equal(noEffort.body.temperature, undefined, 'dropping the budget must not re-add sampling');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('openai: unsupportedParam names the one parameter to drop', () => {
|
|
179
|
+
// The messages OpenAI actually returns.
|
|
180
|
+
assert.equal(
|
|
181
|
+
// Copied verbatim from a real 400 returned by gpt-5.6-luna on 2026-08-25.
|
|
182
|
+
openai.unsupportedParam(400, "Unsupported value: 'temperature' does not support 0.2 with this model. Only the default (1) value is supported."),
|
|
183
|
+
'temperature'
|
|
184
|
+
);
|
|
185
|
+
assert.equal(
|
|
186
|
+
openai.unsupportedParam(400, 'Unrecognized request argument supplied: reasoning_effort'),
|
|
187
|
+
'reasoning_effort'
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// A genuine bad request must NOT be mistaken for a recoverable one, or the pipeline
|
|
191
|
+
// would quietly strip its own parameters instead of surfacing the real problem.
|
|
192
|
+
assert.equal(openai.unsupportedParam(400, 'model not found'), null);
|
|
193
|
+
assert.equal(openai.unsupportedParam(401, 'invalid api key'), null);
|
|
194
|
+
assert.equal(openai.unsupportedParam(429, 'rate limited'), null);
|
|
195
|
+
// Names a parameter we never send, so there is nothing to usefully drop.
|
|
196
|
+
assert.equal(openai.unsupportedParam(400, "Unsupported parameter: 'frequency_penalty'"), null);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('openai: the two capability hooks never both claim the same error', () => {
|
|
200
|
+
const cases = [
|
|
201
|
+
"Unsupported value: 'temperature' does not support 0.2 with this model.",
|
|
202
|
+
'Unrecognized request argument supplied: reasoning_effort',
|
|
203
|
+
"Unknown field 'response_format'",
|
|
204
|
+
'json_schema is not supported',
|
|
205
|
+
];
|
|
206
|
+
for (const text of cases) {
|
|
207
|
+
const json = openai.unsupportedJsonMode(400, text);
|
|
208
|
+
const param = openai.unsupportedParam(400, text);
|
|
209
|
+
assert.ok(!(json && param), `both hooks claimed: ${text}`);
|
|
210
|
+
assert.ok(json || param, `neither hook claimed a capability error: ${text}`);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
134
214
|
test('openai: a custom host is used verbatim — this is how local models work', () => {
|
|
135
215
|
const r = openai.request({ ...ARGS, model: 'qwen2.5:14b', baseUrl: 'http://localhost:11434/v1' });
|
|
136
216
|
assert.equal(r.url, 'http://localhost:11434/v1/chat/completions');
|
package/scripts/translate.mjs
CHANGED
|
@@ -180,7 +180,7 @@ function validate(source, translated) {
|
|
|
180
180
|
* `usage` is normalised to { inTok, outTok } so the caller never sees a provider's
|
|
181
181
|
* own field names.
|
|
182
182
|
*/
|
|
183
|
-
async function callModel(langCode, items, attempt = 1, jsonMode = JSON_MODE) {
|
|
183
|
+
async function callModel(langCode, items, attempt = 1, jsonMode = JSON_MODE, drop = new Set()) {
|
|
184
184
|
const { url, headers, body } = PROVIDER.request({
|
|
185
185
|
model: MODEL,
|
|
186
186
|
system: systemPrompt(langCode),
|
|
@@ -189,14 +189,15 @@ async function callModel(langCode, items, attempt = 1, jsonMode = JSON_MODE) {
|
|
|
189
189
|
key: KEY,
|
|
190
190
|
baseUrl: API_BASE_URL,
|
|
191
191
|
jsonMode,
|
|
192
|
+
drop,
|
|
192
193
|
});
|
|
193
194
|
|
|
194
195
|
const split = async (why, mode = jsonMode) => {
|
|
195
196
|
const mid = Math.ceil(items.length / 2);
|
|
196
197
|
process.stderr.write(` ${why} on ${items.length} units — splitting\n`);
|
|
197
198
|
const [a, b] = await Promise.all([
|
|
198
|
-
callModel(langCode, items.slice(0, mid), 1, mode),
|
|
199
|
-
callModel(langCode, items.slice(mid), 1, mode),
|
|
199
|
+
callModel(langCode, items.slice(0, mid), 1, mode, drop),
|
|
200
|
+
callModel(langCode, items.slice(mid), 1, mode, drop),
|
|
200
201
|
]);
|
|
201
202
|
return {
|
|
202
203
|
rows: [...a.rows, ...b.rows],
|
|
@@ -219,7 +220,7 @@ async function callModel(langCode, items, attempt = 1, jsonMode = JSON_MODE) {
|
|
|
219
220
|
` network error (${String(err.message ?? err).slice(0, 60)}), retry ${attempt} in ${wait}ms\n`
|
|
220
221
|
);
|
|
221
222
|
await new Promise((r) => setTimeout(r, wait));
|
|
222
|
-
return callModel(langCode, items, attempt + 1, jsonMode);
|
|
223
|
+
return callModel(langCode, items, attempt + 1, jsonMode, drop);
|
|
223
224
|
}
|
|
224
225
|
throw err;
|
|
225
226
|
}
|
|
@@ -236,16 +237,26 @@ async function callModel(langCode, items, attempt = 1, jsonMode = JSON_MODE) {
|
|
|
236
237
|
process.stderr.write(
|
|
237
238
|
` server rejected JSON mode "${jsonMode ?? 'schema'}" — retrying with "${next}"\n`
|
|
238
239
|
);
|
|
239
|
-
return callModel(langCode, items, attempt, next);
|
|
240
|
+
return callModel(langCode, items, attempt, next, drop);
|
|
240
241
|
}
|
|
241
242
|
}
|
|
242
243
|
|
|
244
|
+
// The same idea one level down: a server that rejects ONE parameter is telling us
|
|
245
|
+
// about its capabilities, not about a broken run. Drop that parameter and retry.
|
|
246
|
+
// `drop` only ever grows, so each parameter is dropped at most once and this cannot
|
|
247
|
+
// loop. Adapters that do not implement the hook are unaffected.
|
|
248
|
+
const bad = PROVIDER.unsupportedParam?.(res.status, text);
|
|
249
|
+
if (bad && !drop.has(bad)) {
|
|
250
|
+
process.stderr.write(` server rejected "${bad}" — retrying without it\n`);
|
|
251
|
+
return callModel(langCode, items, attempt, jsonMode, new Set(drop).add(bad));
|
|
252
|
+
}
|
|
253
|
+
|
|
243
254
|
const retryable = res.status === 429 || res.status >= 500;
|
|
244
255
|
if (retryable && attempt <= 5) {
|
|
245
256
|
const wait = Math.min(2 ** attempt * 1000, 30000);
|
|
246
257
|
process.stderr.write(` HTTP ${res.status}, retry ${attempt} in ${wait}ms\n`);
|
|
247
258
|
await new Promise((r) => setTimeout(r, wait));
|
|
248
|
-
return callModel(langCode, items, attempt + 1, jsonMode);
|
|
259
|
+
return callModel(langCode, items, attempt + 1, jsonMode, drop);
|
|
249
260
|
}
|
|
250
261
|
throw new Error(`${PROVIDER.label ?? PROVIDER.id} HTTP ${res.status}: ${text.slice(0, 300)}`);
|
|
251
262
|
}
|