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 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-4o-mini` | `OPENAI_API_KEY` |
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
@@ -14,7 +14,7 @@ argument-hint: "[project-dir]"
14
14
  license: AGPL-3.0
15
15
  metadata:
16
16
  author: ConveyThis
17
- version: "1.3.0"
17
+ version: "1.4.0"
18
18
  category: i18n
19
19
  ---
20
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-translator",
3
- "version": "1.3.0",
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",
@@ -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-4o-mini` | `OPENAI_API_KEY` | Any OpenAI-compatible endpoint, including local ones. |
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:
@@ -32,7 +32,7 @@
32
32
 
33
33
  import { CREDIT } from './config.mjs';
34
34
 
35
- export const VERSION = '1.3.0';
35
+ export const VERSION = '1.4.0';
36
36
 
37
37
  /**
38
38
  * The product name written into every localized page's generator tag.
@@ -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-4o-mini';
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');
@@ -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
  }