acuvo-code 0.6.0 → 0.6.1

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/lib/model.mjs CHANGED
@@ -1,1490 +1,1524 @@
1
- /**
2
- * THE MODEL CALL — one provider, one round-trip, and a failure that says what
3
- * to do about it.
4
- *
5
- * ── WHY THIS IS NOT `console/lib/llm.ts` ────────────────────────────────────
6
- * The console's transport is the right thing for the console: a four-provider
7
- * chain with rate-limit-aware reordering, prompt-cache annotation, tier gates
8
- * and a meter. It is also TypeScript that imports `@/lib/codegen-cost` →
9
- * `@/lib/plan-catalog` → the Next path alias, and pulling it in here would drag
10
- * a Next/TS build into a package whose entire point is `node acuvo.mjs` with
11
- * zero install. Re-implementing 700 lines of chain logic would be the fork this
12
- * architecture forbids; calling ONE endpoint with the same message shape is not
13
- * a fork, it is the second client.
14
- *
15
- * ⚠️ SO THE DEBT IS NAMED RATHER THAN HIDDEN: this client is SINGLE-PROVIDER,
16
- * which breaks the house rule "never single". That is acceptable for a local
17
- * developer tool where the failure mode is "the command exits with a message
18
- * you can read" — and unacceptable the moment this path serves a customer. The
19
- * fix when it matters is to extract the console's chain into a dependency-free
20
- * `.mjs` both clients import, not to grow a second chain here.
21
- *
22
- * ── THE FAILURE MESSAGE IS THE FEATURE ──────────────────────────────────────
23
- * A coding agent that hangs, or dies on `Cannot read properties of undefined`,
24
- * is worse than one that does not exist — you cannot tell a broken key from a
25
- * broken tool from a broken network. Every exit from here is a sentence naming
26
- * the cause and the next action, and `classifyHttpFailure` is pure so the whole
27
- * table is testable without spending a cent.
28
- */
29
-
30
- import { TOOL_SCHEMAS } from './tools.mjs';
31
- import { collectStream } from './stream.mjs';
32
- import { resolveCredential } from './account.mjs';
33
-
34
-
35
- /**
36
- * ⭐ v4-flash, measured 2026-08-09 against v3.2-exp on an identical brief:
37
- * $0.000842 vs $0.001465 (1.7x cheaper), 50s vs 95s (1.9x faster), and it
38
- * emitted a correctly SIZED svg icon where v3.2-exp emitted none. Reasoning must
39
- * be off — see the request body below.
40
- */
41
- export const DEFAULT_MODEL = 'deepseek/deepseek-v4-flash-0731';
42
- export const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
43
-
44
- /**
45
- * ── ⭐⭐⭐ DIRECT TO DEEPSEEK — THE ONLY WAY THE CACHE IS RELIABLE ─────────
46
- *
47
- * Roman, 2026-08-19: *"the caching still isn't 90 percent … if it's not 90 our
48
- * product is gone."* He is right, and this is why it was not.
49
- *
50
- * ⚠⚠ MEASURED, and it is not prefix drift. **99.9% of the prompt is
51
- * byte-identical across two completely different tasks** — the tools JSON alone
52
- * is 60,799 chars, 92% of the payload, and never changes. The ceiling is 99.9%.
53
- *
54
- * What actually happens on OpenRouter, measured over four consecutive real runs:
55
- *
56
- * run 1 cache 65% round 1 0%
57
- * run 2 cache 98% round 1 98%
58
- * run 3 cache 31% round 1 0%
59
- * run 4 cache 98% round 1 98%
60
- *
61
- * and on the SAME task three times: 0% → 79% → 99%.
62
- *
63
- * ⭐ THAT IS A ROUTING LOTTERY, NOT A CACHE PROBLEM. A prompt cache lives on ONE
64
- * SERVER. `provider: { order: ['StreamLake'], allow_fallbacks: false }` pins the
65
- * PROVIDER — and StreamLake is a fleet. Pinning the provider does not pin the
66
- * machine, so each run rolls the dice and warms whichever server it landed on.
67
- * No amount of prefix discipline can fix that from our side.
68
- *
69
- * ⚠️ Going direct removes the lottery entirely: one vendor, one endpoint, their
70
- * own automatic context caching, and no aggregator choosing a server for us. It
71
- * is also cheaper, because OpenRouter's margin disappears with it.
72
- *
73
- * ⚠️ OFF UNLESS `DEEPSEEK_API_KEY` IS SET. No key, no behaviour change — this
74
- * cannot silently re-route anyone's traffic or spend on an account they did not
75
- * choose.
76
- */
77
- export const DEEPSEEK_URL = 'https://api.deepseek.com/chat/completions';
78
-
79
- /**
80
- * DeepSeek's own model ids differ from the aggregator's slugs. Mapping only what
81
- * we actually pin; an unmapped model falls through to OpenRouter rather than
82
- * being guessed at, because a wrong id is a 404 that reads like an outage.
83
- */
84
- export const DEEPSEEK_DIRECT_MODELS = Object.freeze({
85
- 'deepseek/deepseek-v4-flash-0731': 'deepseek-chat',
86
- 'deepseek/deepseek-v4-pro-0813': 'deepseek-reasoner',
87
- });
88
-
89
- /**
90
- * Can this call go direct? Requires a key AND a model we can name on their API.
91
- * @returns {{ url: string, apiKey: string, model: string } | null}
92
- */
93
- export function directDeepSeek(model, env = process.env) {
94
- /**
95
- * ── ⭐⭐⭐ OFF UNLESS EXPLICITLY ASKED FOR (Roman, 2026-08-22) ──────────────
96
- *
97
- * *"no direct deepseek api, we can just use the rest of it for testing."*
98
- *
99
- * ⚠️ A KEY BEING PRESENT IS NOT A REQUEST TO USE IT. Before this line, merely
100
- * exporting `DEEPSEEK_API_KEY` silently re-routed every build onto the direct
101
- * endpoint — which is 3.7x dearer on OUTPUT ($0.66/M vs OpenRouter's $0.18/M)
102
- * and doubles for 7 hours a day under DeepSeek's peak billing (01:00-04:00 and
103
- * 06:00-10:00 UTC = 11am-2pm / 4pm-8pm AEST). Measured over 95M tokens that is
104
- * 62.3% margin against 85.6%.
105
- *
106
- * ⭐ Direct's cache READ is genuinely cheaper ($0.007/M vs $0.0154/M) and that
107
- * is why it once led. It cannot pay for the cache MISSES (2.9x dearer) or the
108
- * output (3.7x dearer), and output is ~60% of the bill.
109
- *
110
- * Mirrors `deepSeekDirectEnabled()` in `console/lib/llm.ts` — the builder and
111
- * the CLI must not disagree about which vendor serves a build.
112
- */
113
- if (String(env?.ACUVO_DEEPSEEK_DIRECT ?? '') !== '1') return null;
114
- const key = String(env?.DEEPSEEK_API_KEY ?? '').trim();
115
- if (!key) return null;
116
- const mapped = DEEPSEEK_DIRECT_MODELS[String(model ?? '')];
117
- if (!mapped) return null;
118
- return { url: DEEPSEEK_URL, apiKey: key, model: mapped };
119
- }
120
-
121
- /**
122
- * ── ⚠️⭐ A TEST SEAM THAT CANNOT BECOME AN EXFILTRATION CHANNEL ─────────────
123
- *
124
- * The whole SUCCESS path of this CLI — the report, the change list, the inline
125
- * image rendering — was untestable, because every test drives `bin` with a dead
126
- * key and stops at the refusal. A `ReferenceError` on that path shipped and
127
- * 1,413 green tests said nothing; only a live run found it.
128
- *
129
- * ⚠️ THE OBVIOUS FIX IS DANGEROUS. A plain `ACUVO_API_URL` override redirects
130
- * where `Authorization: Bearer <the user's key>` is SENT. Anything that can set
131
- * an environment variable could then quietly harvest the key, and "env access
132
- * already implies code execution" is a bad excuse for handing it a ready-made
133
- * exfiltration primitive with a documented name.
134
- *
135
- * ⭐ SO IT IS ACCEPTED ONLY FOR LOOPBACK. A test can point it at a server it
136
- * just started on 127.0.0.1; nobody can point it anywhere the key would leave
137
- * this machine. Non-loopback values are not silently ignored either — being
138
- * ignored is how a misconfiguration turns into a mystery — they THROW.
139
- */
140
- export function resolveApiUrl(env = process.env) {
141
- /**
142
- * ── ⭐⭐ AN ACUVO ACCOUNT ROUTES THROUGH OUR GATEWAY, AND ONLY AN ACCOUNT ──
143
- *
144
- * This is the line that makes "buy Acuvo credits, never see a provider key"
145
- * true rather than aspirational, and it is deliberately HERE rather than in
146
- * `callModel`'s signature: every caller — the chain, the refuter, the
147
- * subagent, best-of, the vision leg — reaches the provider through this one
148
- * function, so putting it here means there is no call site that can be
149
- * forgotten. That is the defect class this package loses to most often.
150
- *
151
- * ⚠️ THE ORDER MATTERS AND IT IS NOT ALPHABETICAL. The account is consulted
152
- * BEFORE `ACUVO_API_URL`, because `ACUVO_API_URL` is a loopback-only TEST
153
- * SEAM whose whole justification is that it cannot send a credential off this
154
- * machine. Letting it override a signed-in account would let anything that
155
- * can set an environment variable redirect an authenticated session — the
156
- * exact primitive that restriction exists to deny.
157
- *
158
- * ⚠️ AND BYOK IS NEVER ROUTED HERE. `resolveCredential` returns a null URL
159
- * for a provider key, so a key the user brought is posted to the provider and
160
- * to nobody else. A user's own credential arriving at our servers would be a
161
- * betrayal of the plainest kind, and it is prevented by construction rather
162
- * than by remembering.
163
- */
164
- const credential = resolveCredential(env);
165
- if (credential.mode === 'account' && credential.url) return credential.url;
166
-
167
- const raw = String(env?.ACUVO_API_URL ?? '').trim();
168
- if (!raw) return OPENROUTER_URL;
169
-
170
- let u;
171
- try {
172
- u = new URL(raw);
173
- } catch {
174
- throw new Error(`ACUVO_API_URL is not a URL: ${JSON.stringify(raw)}`);
175
- }
176
- const host = u.hostname.replace(/^\[|\]$/g, '');
177
- const loopback = host === 'localhost' || host === '127.0.0.1' || host === '::1' || /^127\./.test(host);
178
- if (!loopback) {
179
- throw new Error(
180
- `ACUVO_API_URL may only point at loopback (localhost / 127.0.0.1 / ::1); refusing ${u.hostname}. `
181
- + 'This override exists so tests can drive the CLI against a local stub — it is not a way to route your '
182
- + 'API key through another host.',
183
- );
184
- }
185
- return u.toString();
186
- }
187
- /** One round-trip, generous: a coder model writing several whole files is slow,
188
- * and a premature abort looks exactly like a hang to the person waiting. */
189
- export const DEFAULT_TIMEOUT_MS = 180_000;
190
- /**
191
- * ── ⭐ RAISED 8,000 → 12,000 (2026-08-10), AND EVERY DIGIT IS MEASURED ───────
192
- *
193
- * ⚠️ THE OLD CEILING DID NOT PRODUCE A SHORT FILE — IT PRODUCED NO FILE. Asked
194
- * for one large module at `max_tokens: 8000`, the completion was cut off INSIDE
195
- * the `write_file` tool-call JSON and the run died on
196
- * `tool arguments were not valid JSON: Unterminated string at position 27784`.
197
- * Zero bytes written, $0.001522 billed, nothing to show for it. Note this is
198
- * worse than the failure `report.mjs` warns about: the truncation lands in the
199
- * arguments, so it surfaces as a REFUSAL, and the `finishReason === 'length'`
200
- * hint ("re-run with --max-tokens higher") never gets to fire. The user is told
201
- * the model emitted bad JSON, which sounds like a model defect rather than a
202
- * budget they can raise.
203
- *
204
- * ── WHY 12,000 AND NOT A ROUNDER, BIGGER NUMBER ─────────────────────────────
205
- * Measured completion density, twice, on the identical prompt: 27,784 chars of
206
- * tool-argument at 8k and 54,086 at 16k — 3.47 and 3.38 chars/token, 3.29
207
- * marginal. So the cost of re-emitting a whole file is `bytes / 3.29` tokens,
208
- * and this package's OWN lib/ says what that has to cover:
209
- *
210
- * lib/git.mjs 24,110 B → ~7,328 tok fits 8k with 8% to spare
211
- * lib/command.mjs 28,869 B → ~8,775 tok ✖ DID NOT FIT
212
- * lib/policy.mjs 34,635 B → ~10,527 tok ✖ DID NOT FIT
213
- * lib/turn.mjs 80,015 B → ~24,320 tok fits nothing sane
214
- *
215
- * At 8,000 this tool could not rewrite two of its own source files, and cleared
216
- * a third by 8% — one added comment from failing. 12,000 covers policy.mjs (the
217
- * largest plausible single rewrite) with ~14% headroom for the prose note and a
218
- * second tool call in the same response. turn.mjs is deliberately NOT covered:
219
- * sizing the default to re-emit 80KB would be sizing for exactly the case
220
- * `edit_file` exists to prevent.
221
- *
222
- * ── ⚠️ THE UPPER BOUND IS THE TIMEOUT, NOT THE PRICE ────────────────────────
223
- * Measured: a 16,000-token completion took 112s wall-clock. Against
224
- * DEFAULT_TIMEOUT_MS = 180s that puts the real delivery limit near ~25,000
225
- * tokens — above which the default would be advertising a ceiling the default
226
- * timeout cannot pay for. 12,000 lands at ~85s, under half the budget.
227
- *
228
- * ── 💸 COST IMPACT ──────────────────────────────────────────────────────────
229
- * `max_tokens` is a CEILING, billed only on tokens actually generated, so this
230
- * is $0.00 on ordinary work — verified: two real fix-and-verify runs spent
231
- * 11,746 and 16,258 tokens across 3-4 rounds TOTAL (prompt included) and never
232
- * came near 8,000 in a single completion. The only spend that changes is the
233
- * pathological one, where the worst case per round goes
234
- * 8,000 × $0.18/M = $0.00144 → 12,000 × $0.18/M = $0.00216 (+$0.00072).
235
- *
236
- * ⚠️ AND THAT COST IS REAL, WHICH IS THE ARGUMENT AGAINST GOING HIGHER. The 16k
237
- * probe above ALSO truncated — an unbounded request fills whatever ceiling you
238
- * give it, so raising this does not "fix" such a task, it just doubles the bill
239
- * for the same nothing ($0.001522 → $0.002917, measured). Raise to cover real
240
- * files; do not raise to chase a request no ceiling satisfies.
241
- */
242
- export const DEFAULT_MAX_TOKENS = 12_000;
243
-
244
- /**
245
- * Read the model configuration out of the environment.
246
- *
247
- * ⚠️ THE DEFAULT MODEL IS THE CHEAP CODER, DELIBERATELY, and for the reason
248
- * `console/lib/llm.ts` spells out at length: an unset env var must cost little
249
- * and be slightly worse, never cost a lot and be slightly better. The first
250
- * failure mode is visible in the output; the second is visible only on an
251
- * invoice.
252
- *
253
- * ⚠️ AND NOTE THE DRIFT THAT IS REAL: the console defaults to
254
- * `deepseek/deepseek-v3.2-exp` and prices that id in `codegen-cost.ts`. This
255
- * defaults to `deepseek/deepseek-v3.2` (both exist on OpenRouter; the non-exp
256
- * one is the stable release). They are two clients of one capability and they
257
- * should eventually agree — recorded here rather than silently unified, because
258
- * changing the console's priced default is a money decision, not a CLI one.
259
- *
260
- * @param {Record<string, string | undefined>} [env]
261
- * @returns {{ apiKey: string, model: string, configured: boolean }}
262
- */
263
- /**
264
- * ── ⭐⭐ AN ACUVO ACCOUNT COMES FIRST; A PROVIDER KEY STILL WORKS ────────────
265
- *
266
- * Acuvo Code is meant to work the way Claude Code does — you buy Acuvo credits
267
- * and never see a provider key. This function used to read
268
- * `OPENROUTER_API_KEY` out of the user's environment and nothing else, which is
269
- * BYOK and was never the plan.
270
- *
271
- * ⚠️ BYOK IS KEPT, DELIBERATELY. Everyone using this today has that variable
272
- * set; breaking them the day the gateway ships would be the worst possible
273
- * introduction to it. So: an account is PREFERRED, a provider key still WORKS,
274
- * and `mode` says which — because those are two different people's money and
275
- * confusing them is unforgivable.
276
- *
277
- * ⭐ `gatewayUrl` IS NULL FOR BYOK, AND THAT IS THE SECURITY LINE. A provider
278
- * key must never be posted anywhere except the provider. Only an ACUVO token —
279
- * ours, scoped to one account, revocable by us — is ever sent to our gateway.
280
- *
281
- * ⚠️ With only `OPENROUTER_API_KEY` set, every field below is what it was
282
- * before this change, so an existing setup is byte-identical.
283
- *
284
- * @param {Record<string, string | undefined>} [env]
285
- * @returns {{ apiKey: string, model: string, configured: boolean,
286
- * mode: 'account' | 'byok' | 'unconfigured', gatewayUrl: string | null,
287
- * email: string | null }}
288
- */
289
- export function readModelConfig(env = process.env) {
290
- const credential = resolveCredential(env);
291
- const model = (env.OPENROUTER_CODEGEN_MODEL || '').trim() || DEFAULT_MODEL;
292
- return {
293
- apiKey: credential.token,
294
- model,
295
- configured: credential.token.length > 0,
296
- mode: credential.mode,
297
- gatewayUrl: credential.url,
298
- email: credential.email,
299
- };
300
- }
301
-
302
- /**
303
- * ── ⚠️ THIS IS THE FIRST THING A NEW USER EVER SEES ─────────────────────────
304
- *
305
- * They installed it thirty seconds ago and typed a prompt. Whatever this says is
306
- * their entire first impression, and it decides whether they go and get a key or
307
- * close the terminal.
308
- *
309
- * ⚠️ THE PREVIOUS VERSION FAILED TWO WAYS, BOTH INVISIBLE FROM INSIDE THE
310
- * MONOREPO:
311
- * 1. It never said WHERE TO GET A KEY — it explained how to set a variable
312
- * they do not have, answering the second question and skipping the first.
313
- * 2. It suggested `node --env-file=console/.env.local …`, a path that exists
314
- * only in OUR repository. To anyone else that is noise from a tool that has
315
- * clearly never been installed anywhere.
316
- *
317
- * ⭐ Short, one link, one command that works, and the cost stated — because
318
- * "is this going to charge me" is the real unspoken question, and the honest
319
- * answer happens to be excellent.
320
- */
321
- /**
322
- * ── ⚠️⚠️ IT OPENED BY ASKING FOR SOMEBODY ELSE'S PRODUCT ────────────────────
323
- *
324
- * The previous first line was "Acuvo Code needs an OpenRouter key to reach a
325
- * model." A stranger's entire first impression was a demand for a competitor's
326
- * credential, before a single word about what this thing is or why they should
327
- * bother. A dogfood review put it plainly: the storefront sells someone else.
328
- *
329
- * ⭐ SO IT LEADS WITH THE ONE SENTENCE THAT IS ACTUALLY DIFFERENT. Every coding
330
- * agent writes files. This is the only one that quotes the price first and stops
331
- * at the number you gave it, and that is the fact worth spending line one on.
332
- *
333
- * ⭐ AND THE COST IS THE HOOK, NOT A FOOTNOTE. "Is this going to charge me" is
334
- * the real unspoken question, and our honest answer happens to be excellent —
335
- * so it is stated in dollars, with the default ceiling, which turns "how much
336
- * might this cost me" into "two cents, worst case, and I chose it".
337
- *
338
- * ⚠️ IT DOES NOT PROMISE A PLAN. Acuvo Code is intended to be unlocked by an
339
- * Acuvo plan, and that gateway does not exist yet. Writing marketing for a
340
- * product that does not ship is how a first impression becomes a broken
341
- * promise — so this describes exactly what is true today and nothing more.
342
- * When the gateway ships, this message changes with it.
343
- *
344
- * ⚠️ `--doctor` IS NAMED, because it is the best thing we have for someone who
345
- * is stuck: it needs no key, runs offline, and every line it prints names the
346
- * variable that fixes it.
347
- */
348
- /**
349
- * ── ⭐⭐⭐ THE GATEWAY SHIPPED, SO THIS MESSAGE CHANGED WITH IT (2026-08-22) ──
350
- *
351
- * The note above promised exactly that: *"When the gateway ships, this message
352
- * changes with it."* It shipped — `acuvo --login` lands an Acuvo key, and the
353
- * metered path recorded its first real usage row today after never once having
354
- * worked.
355
- *
356
- * ⚠️⚠️ AND UNTIL THIS EDIT THE FRONT DOOR SOLD THE COMPETITION. The first thing
357
- * a brand-new user saw was "create your own OpenRouter key" — BYOK, which Roman
358
- * has ruled out twice, printed as step 1 of onboarding on a package anyone can
359
- * now `npm i -g`. `--help` did list `--login`; the message people actually hit
360
- * did not. Every stranger who installed this brought their own key, so we
361
- * metered nothing and earned nothing.
362
- *
363
- * ⭐ BOTH PATHS STAY, ORDER REVERSED. BYOK is not removed — it is honest, it
364
- * works, and hiding it would make the tool look locked. It is simply no longer
365
- * the default answer to "how do I start".
366
- *
367
- * ⚠️ IT STILL PROMISES NOTHING THAT DOES NOT EXIST. No pricing, no "sign up
368
- * free", no plan names — self-serve signup has never been walked end to end
369
- * (every tenant today is operated · unmetered). It names the two commands that
370
- * are real and stops there.
371
- */
372
- export const MISSING_KEY_MESSAGE = [
373
- 'Acuvo Code — a terminal coding agent that tells you the price before it runs,',
374
- 'stops at the number you set, and can re-check every claim it ever made.',
375
- '',
376
- 'It needs a key. Two ways — then run the same command again:',
377
- '',
378
- ' A) Your Acuvo account, billed to your Acuvo credits:',
379
- ' acuvo --login (paste the key from Settings → API keys)',
380
- '',
381
- ' B) Your own key, billed to you — https://openrouter.ai/keys',
382
- ' export OPENROUTER_API_KEY=sk-or-v1-... (bash / zsh)',
383
- ' $env:OPENROUTER_API_KEY = "sk-or-v1-..." (PowerShell)',
384
- '',
385
- 'A typical task costs $0.001-$0.003. The ceiling is $0.02 a run unless you',
386
- 'raise it, so a mistake costs two cents to find.',
387
- '',
388
- /**
389
- * ⚠️ THE REMEDY MUST RUN ON THE PLATFORM IT IS PRINTED ON. This line was
390
- * `node --env-file=.env "$(which acuvo)" "<prompt>"` for everybody — and
391
- * `$(which acuvo)` is bash. A Windows user, who is exactly the person most
392
- * likely to be reading a "no key" message, pastes it into PowerShell and gets
393
- * a second error on top of the first. ⭐ A remedy that fails is worse than no
394
- * remedy: it converts "I need to set a key" into "this tool is broken".
395
- *
396
- * ⭐ And the simple form is offered first, because `acuvo` loads a `.env`
397
- * beside the project on its own — the explicit invocation is only needed when
398
- * the file lives somewhere else.
399
- */
400
- 'Keep keys in a file? put OPENROUTER_API_KEY=... in a .env beside your project',
401
- ' (acuvo loads it automatically — no extra flags)',
402
- 'Want to check the setup? acuvo --doctor (no key needed, works offline)',
403
- ].join('\n');
404
-
405
- /**
406
- * ── ⚠️⚠️ THE RESPONSE BODY IS NOT TRUSTED TEXT — IT CAN CONTAIN THE KEY ──────
407
- *
408
- * Corporate proxies and API gateways routinely echo the offending REQUEST back
409
- * inside their error page, headers and all. We then printed that body verbatim
410
- * as `detail`, so the key went to terminal scrollback, to CI job logs, and into
411
- * whatever the user pastes into a bug report — three places a secret is very
412
- * hard to recall from. Reproduced 2026-08-10: HTTP 407 with a body of
413
- * `authorization: Bearer sk-or-v1-…` printed the key in full.
414
- *
415
- * ⚠️ THIS RUNS BEFORE THE 400-CHAR SLICE, DELIBERATELY. Truncating first and
416
- * redacting second is worse than not redacting at all: the cut removes the tail
417
- * that made the pattern matchable, so a key straddling char 400 survives as a
418
- * twenty-character prefix that no regex will ever catch again. Measured on the
419
- * unfixed code — `sk-or-v1-STRADDLECAN` made it to the screen.
420
- *
421
- * Whole HEADER LINES go, not just the token: a value we failed to pattern-match
422
- * is still a credential if it sat after `authorization:`.
423
- */
424
- function redact(text) {
425
- return String(text ?? '')
426
- // The credential-bearing header, value and all, whatever shape the value is.
427
- .replace(/^[ \t]*(authorization|proxy-authorization|x-api-key|api-key)[ \t]*:.*$/gim, '<header redacted>')
428
- // OpenRouter's own key format — hyphens included, so it must run before the
429
- // generic rule below, which would otherwise stop at the first hyphen.
430
- .replace(/sk-or-v1-[A-Za-z0-9._-]+/g, 'sk-…redacted')
431
- // Every other provider's `sk-…` key, loose on purpose: a false positive
432
- // costs a reader nothing, a false negative costs them a key.
433
- .replace(/sk-[A-Za-z0-9]{16,}/g, 'sk-…redacted');
434
- }
435
-
436
- /**
437
- * Turn an HTTP status + response body into something a human can act on.
438
- *
439
- * Pure. Every branch here is a real OpenRouter behaviour rather than a guess:
440
- * 402 is what an exhausted balance returns, and it is the single most likely
441
- * failure for this account — measured 2026-08-09, the key authenticates and the
442
- * credits endpoint reports `total_credits: 0` against `total_usage: 0.028`.
443
- *
444
- * ⚠️ THE THIRD ARGUMENT IS OPTIONAL AND EVERY EXISTING CALLER STAYS CORRECT.
445
- * `pin` is the provider preference that was SENT with the failed request, and it
446
- * exists because of a measured misdiagnosis: `ACUVO_PROVIDER_ORDER=DeepSeek`
447
- * returns HTTP 404, and the 404 branch below told the reader to check
448
- * `OPENROUTER_CODEGEN_MODEL` against the model catalogue. The model was fine.
449
- * Every word of the advice pointed away from the one variable that caused it —
450
- * and because the message matches `isModelSpecific`, `chain.mjs` then spent all
451
- * four attempts re-sending the SAME bad pin against four different model ids.
452
- */
453
- export function classifyHttpFailure(status, bodyText, { pin = null } = {}) {
454
- /**
455
- * Rendered once, used only by the branches where a pin can plausibly be the
456
- * cause. An empty or absent pin adds nothing, so an unpinned run's messages
457
- * are byte-identical to what they were before this argument existed.
458
- */
459
- const pinClause = Array.isArray(pin) && pin.length > 0
460
- ? `\n\n⚠️ ACUVO_PROVIDER_ORDER=${pin.join(',')} was sent with this request. A provider that does not `
461
- + 'serve this model — or one excluded by your OpenRouter data policy — makes the request a 404 even '
462
- + 'though the model id is fine. Unset it to rule the pin out before you change the model.'
463
- : '';
464
- const snippet = redact(bodyText || '').slice(0, 400).trim();
465
- let apiMessage = '';
466
- try {
467
- // ⚠️ Parsed from the ORIGINAL body (redaction would not break JSON here, but
468
- // relying on that is a trap), then redacted on the way out — the provider's
469
- // own message is just as capable of quoting the key back at us.
470
- apiMessage = redact(JSON.parse(bodyText)?.error?.message || '');
471
- } catch {
472
- /* a non-JSON body is itself information; the snippet carries it */
473
- }
474
- const detail = apiMessage || snippet || '(no response body)';
475
-
476
- if (status === 401 || status === 403) {
477
- return `OpenRouter rejected the API key (HTTP ${status}). Check OPENROUTER_API_KEY is current and not revoked.\n ${detail}`;
478
- }
479
- if (status === 402) {
480
- return `OpenRouter says this account cannot pay for the call (HTTP 402) — the balance is exhausted.\n ${detail}\n\nTop up at https://openrouter.ai/credits, or set OPENROUTER_CODEGEN_MODEL to a ":free" model id.`;
481
- }
482
- if (status === 404) {
483
- return `OpenRouter does not serve that model (HTTP 404). Check OPENROUTER_CODEGEN_MODEL against https://openrouter.ai/models.\n ${detail}${pinClause}`;
484
- }
485
- if (status === 429) {
486
- return `Rate limited by OpenRouter (HTTP 429). Wait and re-run, or switch OPENROUTER_CODEGEN_MODEL.\n ${detail}`;
487
- }
488
- if (status >= 500) {
489
- return `OpenRouter or the upstream provider failed (HTTP ${status}). This is usually transient — re-run.\n ${detail}`;
490
- }
491
- return `The model call failed (HTTP ${status}).\n ${detail}`;
492
- }
493
-
494
- /**
495
- * ── ⚠️ `err.message` IS ALWAYS THE LITERAL STRING 'fetch failed' ─────────────
496
- *
497
- * Node's fetch wraps every transport fault in one TypeError with that exact
498
- * message and hangs the real cause off `err.cause.code`. Reading only `.message`
499
- * therefore printed IDENTICAL text for a DNS failure, a refused connection, a
500
- * corporate TLS MITM and a captive portal — four different problems with four
501
- * different fixes, all reported as "Could not reach OpenRouter: fetch failed".
502
- * `lib/github.mjs:107` already reads the cause code; this is that shape, with
503
- * the fix attached.
504
- */
505
- const TRANSPORT_CAUSES = {
506
- ENOTFOUND: 'the hostname did not resolve — check DNS or your network',
507
- EAI_AGAIN: 'DNS lookup timed out — the resolver is unreachable or overloaded',
508
- ECONNREFUSED: 'the connection was refused — a proxy or firewall closed it',
509
- ECONNRESET: 'the connection was reset mid-request',
510
- ETIMEDOUT: 'the connection timed out before the server answered',
511
- UND_ERR_SOCKET: 'the socket closed before the response finished',
512
- DEPTH_ZERO_SELF_SIGNED_CERT: 'a TLS certificate could not be verified — if you are behind a corporate proxy, set NODE_EXTRA_CA_CERTS=/path/to/ca.pem',
513
- SELF_SIGNED_CERT_IN_CHAIN: 'a TLS certificate could not be verified — if you are behind a corporate proxy, set NODE_EXTRA_CA_CERTS=/path/to/ca.pem',
514
- UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'a TLS certificate could not be verified — if you are behind a corporate proxy, set NODE_EXTRA_CA_CERTS=/path/to/ca.pem',
515
- };
516
-
517
- /**
518
- * Everything that can go wrong before a reply exists, as one sentence.
519
- * Separated from the fetch so the table above is testable, and so a transport
520
- * exception is never re-thrown as a raw stack.
521
- *
522
- * ⚠️⚠️ THE PHRASE 'Could not reach OpenRouter' IS AN API, NOT PROSE.
523
- * `lib/chain.mjs:81` decides retryability by matching error TEXT, and connection
524
- * failures only fall back to a second provider because they happen to match
525
- * `/could not reach/i`. Reword this prefix and you silently switch fallback off
526
- * for the entire class of faults fallback exists for. Change the sentence after
527
- * it as much as you like; leave those four words alone.
528
- */
529
- /**
530
- * ── ⭐⭐ THE KIND, SO RETRYABILITY STOPS DEPENDING ON A SENTENCE ─────────────
531
- *
532
- * The header above says the phrase 'Could not reach OpenRouter' is an API
533
- * because `chain.mjs` matches error TEXT. That warning was right and it was
534
- * also incomplete: the TIMEOUT branch never matched anything `isRetryable`
535
- * looked for. Measured 2026-08-12:
536
- *
537
- * isRetryable(describeTransportError({name:'TimeoutError'}, 180000)) === false
538
- *
539
- * So the four-model chain never fired on a timeout — the commonest failure of a
540
- * LONG job, with three healthy fallbacks sitting right there. Long tasks failed
541
- * more, by design, which is exactly backwards. And the suite stayed green
542
- * because its test asserted `isRetryable('timed out')`, a literal this function
543
- * has never produced.
544
- *
545
- * ⭐ A WIDER REGEX WOULD ONLY MOVE THE NEXT DRIFT. The classifier should not be
546
- * reading English at all. This returns the fact; `isRetryable` switches on it,
547
- * and the sentence becomes free to reword.
548
- *
549
- * @param {any} err
550
- * @returns {'timeout' | 'network' | null}
551
- */
552
- export function transportErrorKind(err) {
553
- const name = err?.name || '';
554
- if (name === 'TimeoutError' || name === 'AbortError') return 'timeout';
555
- const code = err?.cause?.code || err?.code || '';
556
- if (code && TRANSPORT_CAUSES[code]) return 'network';
557
- // ⚠️ An uncatalogued code is still a transport fault — that is what a `cause`
558
- // code MEANS. Treating only known codes as network is how ECONNRESET's
559
- // less-famous siblings quietly stopped failing over.
560
- if (code) return 'network';
561
- return null;
562
- }
563
-
564
- export function describeTransportError(err, timeoutMs) {
565
- const name = err?.name || '';
566
- const message = err instanceof Error ? err.message : String(err);
567
- if (name === 'TimeoutError' || name === 'AbortError') {
568
- return `No response from OpenRouter within ${Math.round(timeoutMs / 1000)}s — the call was aborted rather than left hanging.`;
569
- }
570
- const code = err?.cause?.code || err?.code || '';
571
- const known = TRANSPORT_CAUSES[code];
572
- if (known) return `Could not reach OpenRouter: ${known} (${code}).`;
573
- // Unknown cause: say the code anyway if there is one. A code we have not
574
- // catalogued is still searchable; 'fetch failed' on its own is not.
575
- if (code) return `Could not reach OpenRouter: ${message} (${code}). Check the network, DNS, and any proxy.`;
576
- return `Could not reach OpenRouter: ${message}\nCheck the network, DNS, and any proxy between you and openrouter.ai.`;
577
- }
578
-
579
- /**
580
- * The assistant message out of an OpenAI-shaped body, or a reason it is absent.
581
- *
582
- * @typedef {{ function?: { name?: string, arguments?: string } }} RawToolCall
583
- * @typedef {{ ok: true, content: string | null, toolCalls: RawToolCall[], finishReason: string | null, usage: { cost?: number, total_tokens?: number } | null }} ReplyOk
584
- * @param {any} body
585
- * @returns {ReplyOk | { ok: false, error: string }}
586
- */
587
- export function extractReply(body) {
588
- const choice = body?.choices?.[0];
589
- if (!choice) return { ok: false, error: 'the model returned no choices — nothing to act on' };
590
- const message = choice.message;
591
- if (!message) return { ok: false, error: 'the model returned a choice with no message' };
592
- const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
593
-
594
- /**
595
- * ── ⚠️⚠️ THE DEGENERATE 200: BILLED, AND WITH NOTHING IN IT ────────────────
596
- *
597
- * A provider can answer 200 with a message carrying neither content nor a
598
- * tool call. The call happened, the tokens were billed, and there is nothing
599
- * to act on. This used to return `ok: true` with `content: null`, and that
600
- * travelled all the way out as a FINISHED SESSION — exit 0, zero files
601
- * changed, no error printed, and no fallback ever attempted.
602
- *
603
- * ⭐ AND IT MADE chain.mjs's OWN GUARD DEAD CODE. That file carries
604
- * `if (/empty reply|no content|returned nothing/i.test(e)) return true;` and
605
- * calls it "the single most important line here" — but it classifies an ERROR
606
- * STRING, and this function never produced one for this case. So the chain
607
- * had three healthy fallback models it could never reach on the one failure
608
- * that costs a whole call. The wording below is chosen to match that pattern;
609
- * `test/transport-empty-reply.test.mjs` asserts the two agree, so renaming
610
- * this message without updating the classifier fails a test rather than
611
- * silently re-opening the hole.
612
- *
613
- * ⚠️ `content: null` WITH TOOL CALLS IS THE NORMAL SHAPE — it is what every
614
- * tool-calling turn looks like, and some providers send `''` rather than
615
- * null. Only the case with NEITHER is degenerate. Widening this check to
616
- * "content is empty" would refuse every tool call in the product.
617
- */
618
- const hasText = typeof message.content === 'string' && message.content.trim() !== '';
619
- if (!hasText && toolCalls.length === 0) {
620
- return {
621
- ok: false,
622
- error: 'the model returned an empty reply — no content and no tool calls, so there is nothing to act on',
623
- };
624
- }
625
-
626
- return {
627
- ok: true,
628
- content: typeof message.content === 'string' ? message.content : null,
629
- toolCalls,
630
- finishReason: choice.finish_reason ?? null,
631
- // ⚠️ OpenRouter reports the REAL cost of the call in `usage.cost`. Printing
632
- // it is not decoration: this repo's standing rule is that pre-revenue every
633
- // infra dollar is burn, and a local tool that spends silently is exactly how
634
- // a binge happens without anyone noticing.
635
- usage: body?.usage ?? null,
636
- /**
637
- * ⭐ THE UPSTREAM THAT ACTUALLY SERVED IT. OpenRouter puts the serving
638
- * provider's name on the response body next to `model`, and this function
639
- * discarded every top-level field it did not name — so the one fact that
640
- * distinguishes "our prefix regressed" from "we were routed to a cold
641
- * instance" arrived on every call and was thrown away. Absent stays null:
642
- * a provider that does not report it is unknown, not "unpinned".
643
- */
644
- provider: typeof body?.provider === 'string' && body.provider ? body.provider : null,
645
- };
646
- }
647
-
648
- /**
649
- * ── ⭐⭐ DID THE PIN TAKE? ────────────────────────────────────────────────────
650
- *
651
- * ⚠️ THE DEFECT THIS ANSWERS, MEASURED 2026-08-14: `ACUVO_PROVIDER_ORDER=DeepSeek`
652
- * returns HTTP 404 "No endpoints found" when it is sent alone — and with
653
- * `allow_fallbacks: true` (which stays true, see the payload) OpenRouter does not
654
- * error on an `order` list it cannot honour. It treats it as an empty preference
655
- * and routes at random. So a pin has THREE outcomes, not two: honoured, rejected
656
- * loudly, and **accepted, ignored, billed** — and the third was indistinguishable
657
- * from the first at every layer of this CLI. The measured cost of not knowing:
658
- * 46.7% hit rate instead of 95.8%, i.e. roughly 2.4× the bill, with no symptom.
659
- *
660
- * ⚠️ THE COMPARISON IS CASE-INSENSITIVE ON PURPOSE. The catalogue writes
661
- * `DeepInfra`; people type `deepinfra`. A pin that "did not take" because of a
662
- * capital letter would be a false alarm, and one false alarm is all it takes for
663
- * the real one to be ignored.
664
- *
665
- * ── ⚠️⚠️ A LIST IS NOT ONE CACHE, AND "took" USED TO PRETEND IT WAS ─────────
666
- *
667
- * This function used to answer `took` for ANY name in the list, on the reasoning
668
- * that a preference list is honoured if any of its names served the round. That
669
- * is the right test for AVAILABILITY and the wrong one for the thing the pin
670
- * exists to buy. **A prompt cache lives on ONE upstream instance.** Landing on
671
- * the second name in the list is a live provider and a stone-cold cache, and it
672
- * was scored identically to landing on the first.
673
- *
674
- * ⭐ MEASURED 2026-08-16, replaying ONE byte-identical 46,171-byte payload
675
- * against `order: [StreamLake, Baidu, GMICloud]`:
676
- *
677
- * served by StreamLake (first choice) 11,520 of 11,714 cached 98.3% $0.000172
678
- * served by Baidu (second choice) 0 of 11,714 cached 0.0% $0.000791
679
- *
680
- * **4.6× on one round, for the same bytes**, and every layer of this CLI called
681
- * it `pinTook: 1, pinMissed: 0` — a healthy reading. Over 40 pinned calls the
682
- * scatter measured StreamLake 38, Baidu 2, so this is a ~5% event that nothing
683
- * could see and nothing could name.
684
- *
685
- * ⚠️ THE FALLBACK IS STILL NOT THE BUG. `allow_fallbacks` stays true — "never
686
- * single", and a cheaper request that does not happen is not cheaper. What
687
- * changes here is only that a fallback stops being invisible, which is the same
688
- * argument that put `missed` here in the first place.
689
- *
690
- * @param {{ pin?: string[] | null, served?: string | null }} x
691
- * @returns {'none' | 'unknown' | 'took' | 'fell-back' | 'missed'}
692
- * `none` — nothing was pinned. `unknown` — pinned, but the provider never said
693
- * who served it, so we refuse to guess either way. `took` — the FIRST name
694
- * served it, which is the only outcome that reuses the cache we have been
695
- * accumulating. `fell-back` — a later name in the list served it: available,
696
- * billed, and cold. `missed` — nobody in the list served it.
697
- */
698
- export function pinOutcome({ pin = null, served = null } = {}) {
699
- if (!Array.isArray(pin) || pin.length === 0) return 'none';
700
- if (typeof served !== 'string' || !served) return 'unknown';
701
- const want = pin.map((p) => String(p).trim().toLowerCase());
702
- const got = served.trim().toLowerCase();
703
- if (want[0] === got) return 'took';
704
- return want.includes(got) ? 'fell-back' : 'missed';
705
- }
706
-
707
- /**
708
- * ── ⚠️ THE DEFAULT PIN IS THE OWNER'S DECISION, NOT THIS FILE'S ─────────────
709
- *
710
- * Empty = today's behaviour exactly: no `provider` field is sent and routing is
711
- * whatever OpenRouter chooses. Naming a provider here would make a specific third
712
- * party our default route for every request this package makes — a commercial
713
- * decision, not an engineering one, so it is left switched off with the switch in
714
- * plain sight.
715
- *
716
- * ⭐ TO TURN IT ON: set this string to a provider name (measured best on
717
- * 2026-08-14: `'DeepInfra'` — 73.7% and 95.8% hit rates against 46.7% and 48.6%
718
- * unpinned on the identical 4-round task). `ACUVO_PROVIDER_ORDER` still overrides
719
- * it per-run, and `pinOutcome` above now makes a pin that does not take visible
720
- * rather than silent, which is what makes flipping this safe to try.
721
- */
722
- /**
723
- * ── ⭐⭐ THE PIN IS ON, AND IT IS THE WHOLE CACHING STORY ────────────────────
724
- *
725
- * 28 upstream endpoints serve this model. A prompt cache lives on ONE instance,
726
- * so unpinned we are re-routed across all of them and the cache is cold more
727
- * often than not. MEASURED 2026-08-14, same task, same day:
728
- *
729
- * unpinned 48.6% and 46.7% cache hit $0.002217/task
730
- * pinned 73.7% and 95.8% cache hit $0.000910/task 2.4x cheaper
731
- *
732
- * And an isolated probe of three identical calls: unpinned went Relace →
733
- * GMICloud → GMICloud and only hit 97.1% on the third BY LUCK; pinned, every
734
- * call after the first hit 97.1%.
735
- *
736
- * ── ⭐ WHY THIS PROVIDER, RANKED BY THE ONLY NUMBER THAT MATTERS ────────────
737
- *
738
- * Not the headline per-token price — the EFFECTIVE cost at our real cache rate
739
- * (95.8%) on our real blend (90% input). Measured live from the endpoint feed:
740
- *
741
- * StreamLake $0.0327/M DeepInfra $0.0348/M
742
- * Baidu $0.0327/M DeepSeek $0.0357/M
743
- * Decart $0.0332/M GMICloud $0.0373/M
744
- *
745
- * StreamLake is cheapest, and is what the bench already lands on — it hit 98%
746
- * cache on a real task this morning.
747
- *
748
- * ⚠️⚠️ AND THE HAZARD THE PIN CLOSES IS WORSE THAN THE PRICE SPREAD: **1 of the
749
- * 28 endpoints publishes no cache-read price at all.** Unpinned, a run can land
750
- * on the one provider that never caches anything, and nothing would say so —
751
- * the bill would simply be five times larger with an identical transcript.
752
- *
753
- * ⚠️ THIS IS A PREFERENCE, NOT A LOCK. `allow_fallbacks` stays true, so an
754
- * outage at StreamLake degrades to another endpoint rather than killing every
755
- * run at once. "Never single" is this package's standing rule and pinning hard
756
- * would trade an outage for a discount — a cheaper request that does not happen
757
- * is not cheaper.
758
- *
759
- * ⚠️ AND A PIN THAT DOES NOT TAKE IS REPORTED. `pinOutcome` compares what was
760
- * asked for against what actually served the round, because the failure mode of
761
- * a silent pin is a worse bill and no symptom — which is exactly how a bad pin
762
- * fooled a measurement here once already.
763
- *
764
- * ⭐ Override or disable with `ACUVO_PROVIDER_ORDER` (empty string = unpinned).
765
- */
766
- export const DEFAULT_PROVIDER_ORDER = 'StreamLake';
767
-
768
- /**
769
- * ── ⚠️⚠️ A SINGLE GLOBAL PIN IS ONLY EVER CORRECT FOR ONE MODEL ─────────────
770
- *
771
- * `DEFAULT_PROVIDER_ORDER = 'StreamLake'` was chosen by measuring FLASH, and
772
- * **StreamLake does not serve pro at all** — it is not among pro's 7 endpoints.
773
- * So every pro run asked for a provider that could not answer, the pin matched
774
- * nothing, `allow_fallbacks` did its job, and OpenRouter routed freely.
775
- *
776
- * MEASURED on the 13-task bench, 2026-08-15: **pro was served by GMICloud on
777
- * 13 of 13 runs.** Compare the two pro endpoints:
778
- *
779
- * DeepSeek (the model's author) in $0.435 out $0.870 cache-read $0.0036
780
- * GMICloud (what we actually got) in $1.218 out $2.436 cache-read $0.1015
781
- *
782
- * ⚠️ 2.8x on tokens and **28x on cache reads**. The "pro costs 11.2x flash"
783
- * figure this package now quotes was measured on the most expensive pro
784
- * endpoint available, because nobody had pinned the cheap one. Pinned to
785
- * DeepSeek's own endpoint, pro's cached reads ($0.0036) are ~3.8x CHEAPER than
786
- * flash's ($0.0137).
787
- *
788
- * ⭐ SO THE PIN IS PER-MODEL. A provider list is a fact about a MODEL, not
789
- * about this package, and pretending otherwise silently unpins every model
790
- * except the one that was measured.
791
- *
792
- * ⚠️ EACH ENTRY IS A LIST, NOT ONE NAME. A single name plus `allow_fallbacks`
793
- * degrades to *anything* when that provider is down — which is how a cheap run
794
- * becomes an expensive one with no symptom. Two or three cheap endpoints in
795
- * order degrade to another CHEAP one first. Still a preference, never a lock:
796
- * "never single" is the standing rule and a cheaper request that does not
797
- * happen is not cheaper.
798
- *
799
- * Prices read from OpenRouter's per-model endpoint feed on 2026-08-15; the
800
- * ORDER is what matters and it is cheapest-first by in+out.
801
- */
802
- /**
803
- * ── ⚠️⚠️ AND A NAME IN THIS TABLE IS NOT PROOF IT CAN BE REACHED ────────────
804
- *
805
- * MEASURED 2026-08-16 against the live account, `allow_fallbacks:false`, one
806
- * name at a time:
807
- *
808
- * flash StreamLake ✔ Baidu ✔ GMICloud ✔ Decart ✔ **DeepSeek ✘ 404**
809
- * pro GMICloud ✔ **DeepSeek ✘ 404**
810
- *
811
- * `DeepSeek` — the model's own author, the cheapest endpoint on both models,
812
- * `status: 0` and `uptime_last_30m: 100` in the public feed — answers **"No
813
- * endpoints found"** for this account on BOTH models, with or without any
814
- * parameter. That is an OpenRouter **data-policy exclusion**, not a typo and not
815
- * an outage, and it is fixed in the account settings, not here.
816
- *
817
- * ⚠️ SO PRO'S PIN HAS ALWAYS RESOLVED TO GMICloud ALONE, and every layer called
818
- * it `pinTook`. That is exactly the silence `pinFellBack` was added to end: pro
819
- * runs now say "DeepSeek did not serve N rounds" instead of nothing.
820
- *
821
- * ⭐ THE NAME STAYS ANYWAY, first. It is the right endpoint the moment the
822
- * policy allows it — **eff. $0.098/M against GMICloud's $0.355/M at a 98% cache
823
- * rate on a 90/10 blend, 3.6× cheaper** — and deleting it would quietly convert
824
- * a fixable account setting into a permanent 3.6× overpayment nobody remembers.
825
- *
826
- * ⚠️ WHAT WAS ACTUALLY WRONG WITH PRO'S LIST IS THAT IT WAS ONE REACHABLE NAME.
827
- * The rule two paragraphs up — "a single name plus `allow_fallbacks` degrades to
828
- * *anything*" — was being broken by the pin that had a second entry on paper.
829
- * With GMICloud down, pro degraded to whatever answered: SiliconFlow is
830
- * $0.808/M, 2.3× GMICloud. Fireworks and Cloudflare are the next-cheapest
831
- * REACHABLE endpoints ($0.459/M) and are named so the degradation is cheap-first.
832
- *
833
- * Prices re-read from the live endpoint feed 2026-08-16.
834
- */
835
- export const PROVIDER_PIN_BY_MODEL = Object.freeze({
836
- // 28 endpoints. The three cheapest are within 3% of each other, and all three
837
- // are reachable (checked one at a time, 2026-08-16).
838
- 'deepseek/deepseek-v4-flash-0731': Object.freeze(['StreamLake', 'Baidu', 'GMICloud']),
839
- // 8 endpoints, and the spread is enormous — this is the one that was costing us.
840
- // ⚠️ `DeepSeek` is 404 for this account (see above); GMICloud is the cheapest
841
- // endpoint we can actually reach, and the two after it keep the fall cheap.
842
- 'deepseek/deepseek-v4-pro-0813': Object.freeze(['DeepSeek', 'GMICloud', 'Fireworks', 'Cloudflare']),
843
- // Exactly one endpoint; pinning it changes nothing today and states the fact.
844
- 'qwen/qwen3.7-flash': Object.freeze(['Alibaba']),
845
- 'z-ai/glm-4.6': Object.freeze(['Venice', 'DeepInfra']),
846
- });
847
-
848
- /**
849
- * The provider order to ask for, given the model about to be called.
850
- *
851
- * @param {string} model
852
- * @param {Record<string,string|undefined>} [env]
853
- * @returns {{ order: string[], source: 'env' | 'model' | 'default' | 'none' }}
854
- */
855
- export function providerOrderFor(model, env = process.env) {
856
- const raw = env?.ACUVO_PROVIDER_ORDER;
857
- /**
858
- * ⚠️ UNSET vs EXPLICITLY EMPTY, and the difference is the off switch. An
859
- * explicit '' means "do not pin at all" and must not fall through to a
860
- * default — a `??` here was a real bug once, where the documented way to
861
- * unpin quietly did nothing.
862
- */
863
- if (raw !== undefined && raw !== null) {
864
- const order = String(raw).split(',').map((s) => s.trim()).filter(Boolean);
865
- return { order, source: order.length ? 'env' : 'none' };
866
- }
867
- const byModel = PROVIDER_PIN_BY_MODEL[String(model ?? '')];
868
- if (byModel && byModel.length) return { order: [...byModel], source: 'model' };
869
- /**
870
- * ⚠️ AN UNKNOWN MODEL IS LEFT UNPINNED RATHER THAN GIVEN FLASH'S PIN. Asking
871
- * for a provider that does not serve the model is exactly the bug above: it
872
- * looks pinned, matches nothing, and routes freely to whatever is dearest.
873
- * No pin at least tells the truth, and `pinOutcome` reports it.
874
- */
875
- return { order: [], source: 'none' };
876
- }
877
-
878
- /**
879
- * One completion. Never throws — returns `{ ok }` either way, because the
880
- * caller's job is to print a summary, not to catch.
881
- *
882
- * @returns {Promise<(ReplyOk & { model: string }) | { ok: false, error: string }>}
883
- */
884
- /**
885
- * ── ⭐ STREAMING IS OPT-IN PER CALL, NOT A MODE ─────────────────────────────
886
- * `onText` present = stream. Absent = the exact previous behaviour, byte for
887
- * byte. That keeps every existing test, the bench, and any caller that wants a
888
- * whole answer unchanged — a global switch would have made "did streaming break
889
- * this?" a question on every future bug.
890
- */
891
- export async function callModel({
892
- apiKey, model, messages, onText = null,
893
- // ⚠️ THE CALLER CHOOSES WHAT TO OFFER. Defaulting to the whole registry keeps
894
- // this honest for a future multi-round client; the single-shot turn narrows
895
- // it deliberately (see SINGLE_SHOT_TOOL_NAMES and the measurement behind it).
896
- tools = TOOL_SCHEMAS,
897
- timeoutMs = DEFAULT_TIMEOUT_MS, maxTokens = DEFAULT_MAX_TOKENS, fetchImpl = fetch,
898
- /**
899
- * ⚠️ Off only for a test that asserts the single-attempt payload. Production
900
- * never sets it — the warm-first attempt IS the cache floor, and a flag that
901
- * quietly disables it would be the defect this change exists to remove.
902
- */
903
- retryOnPinFailure = true,
904
- // ⚠️ Injected so the provider preference below is testable without touching
905
- // the real environment — and so a library caller can set it explicitly.
906
- env = process.env,
907
- /**
908
- * ── ⭐ AN OBSERVED ROUTE, NOT A CONFIGURED ONE ────────────────────────────
909
- *
910
- * `warm-provider.mjs` watches who ACTUALLY served earlier rounds and asks for
911
- * that one name with fallbacks off, because a prompt cache lives on a single
912
- * upstream and `provider.order` is only a preference. Measured live: a round
913
- * that landed on the pin's second name was 0% cached and 4.6× the price for
914
- * byte-identical input.
915
- *
916
- * ⚠️ STRICT IS SAFE HERE ONLY BECAUSE THE NAME WAS SEEN TO SERVE. Strictness
917
- * on a CONFIGURED name is a single point of failure — that is why
918
- * `ACUVO_PROVIDER_STRICT` is opt-in, and this deliberately does not reuse it.
919
- * `null` leaves every existing caller byte-identical.
920
- */
921
- routeOverride = null,
922
- /**
923
- * ── ⭐⭐⭐ THE STICKY KEY — AND THE FEATURE OUR OWN FIX WAS SWITCHING OFF ───
924
- *
925
- * Everything this file says about the routing lottery is correct: a prompt
926
- * cache lives on ONE upstream, `provider.order` is only a preference over
927
- * PROVIDERS, and StreamLake is a fleet — so pinning the provider never pinned
928
- * the machine, and successive cold processes measured 65 / 98 / 31 / 98.
929
- *
930
- * ⚠️⚠️ WHAT NONE OF THAT NOTICED IS THAT OPENROUTER SOLVES THIS, AND WE WERE
931
- * DISABLING IT. Their prompt-caching documentation, verbatim:
932
- *
933
- * "Sticky routing is not used when you specify a manual provider order via
934
- * `provider.order` — in that case, your explicit ordering takes priority."
935
- *
936
- * "When `session_id` is set, sticky routing activates on any successful
937
- * request — even before cache usage is observed — so that subsequent
938
- * requests in the same session benefit from prompt caching from the start."
939
- *
940
- * ⭐ So the warm-first pin — the change written specifically to win the cache
941
- * back — is the one thing that turns off the mechanism that pins the actual
942
- * SERVER. We diagnosed "pinning the provider does not pin the machine" and
943
- * then concluded the machine could not be pinned; in fact it can, and our pin
944
- * was what stopped it. That is why round 1 was always 0% and why the same
945
- * task warmed 0 → 79 → 99 instead of starting warm.
946
- *
947
- * ⚠️ UNPROVEN UNTIL MEASURED, AND SAID PLAINLY. This is read from their docs,
948
- * not from our own numbers — the honest test is four cold runs sharing a
949
- * session id, which needs credits. It is defensible before that measurement
950
- * only because it cannot be worse: `session_id` is inert if stickiness never
951
- * engages, and `only` restricts exactly what `order` restricted for a
952
- * one-element list. `null` leaves every existing caller byte-identical.
953
- */
954
- sessionId = null,
955
- }) {
956
- const streaming = typeof onText === 'function';
957
- /**
958
- * ── ⭐⭐ CACHE STICKINESS: THE PREFIX IS PERFECT AND THE ROUTING IS NOT ─────
959
- *
960
- * Measured 2026-08-12 with a scripted model, so the numbers are about the
961
- * bytes WE send: **97.0% and 97.9% of rounds 2 and 3 were a byte-identical
962
- * re-send** of the previous round, and the `tools` array was identical every
963
- * round. Our side of the cache contract is essentially optimal.
964
- *
965
- * ⚠️ AND REAL RUNS THE SAME DAY REPORTED 0%, 32%, 33% HIT RATES. The gap is
966
- * not ours: a prompt cache lives on ONE upstream instance, and OpenRouter is
967
- * free to route each round to a different provider behind the same model id —
968
- * this file already documents that varying ("Baidu vs StreamLake on the same
969
- * model id"). Round 2 landing elsewhere is a cold cache no prefix discipline
970
- * can fix.
971
- *
972
- * ⭐ `ACUVO_PROVIDER_ORDER` (comma-separated) pins the preference so successive
973
- * rounds tend to reach the same instance.
974
- *
975
- * ⚠️ `allow_fallbacks` STAYS TRUE, and that is not a detail. "Never single"
976
- * is this package's standing rule: pinning hard would trade an outage for a
977
- * discount, and a cheaper request that does not happen is not cheaper. This
978
- * expresses a PREFERENCE and keeps the chain underneath it.
979
- *
980
- * ⚠️ OFF UNLESS SET. Provider names are an OpenRouter catalogue detail that
981
- * changes without notice, and inventing one would route every request at a
982
- * provider that may not serve this model — so the default sends no `provider`
983
- * field at all and behaves exactly as before. `DEFAULT_PROVIDER_ORDER` is the
984
- * one-line switch that changes that, and it is deliberately empty.
985
- *
986
- * ⚠️⚠️ AND A PIN THAT DOES NOT TAKE IS SILENT — measured, 2026-08-14. See
987
- * `pinOutcome` above for the three outcomes and what the silence costs. The
988
- * fallback is NOT the bug and is not being removed; the silence is, and the
989
- * pin now travels back on the reply so the round record can name it.
990
- */
991
- /**
992
- * ⚠️⚠️ WHETHER THE PIN WAS CHOSEN BY A HUMAN OR INHERITED FROM OUR DEFAULT,
993
- * AND THE DIFFERENCE IS LOAD-BEARING FOR `ACUVO_PROVIDER_STRICT`.
994
- *
995
- * Strict turns a pin into `allow_fallbacks:false` — an outage becomes a 404
996
- * instead of a re-route. That is correct for a benchmark and catastrophic as
997
- * an inherited default: the day StreamLake has a bad ten minutes, every run
998
- * dies at once, which is precisely the "never single" failure this package
999
- * refuses to accept.
1000
- *
1001
- * ⭐ So strict applies ONLY to a pin somebody named. Our default is a
1002
- * PREFERENCE and can never be promoted to a lock by a second flag. A test
1003
- * caught this the moment the default was switched on — `ACUVO_PROVIDER_STRICT`
1004
- * alone used to be inert, and without this it would silently have become a
1005
- * hard lock on a provider the user never chose.
1006
- */
1007
- /**
1008
- * ⚠️ UNSET AND EXPLICITLY-EMPTY ARE DIFFERENT, AND CONFLATING THEM COSTS THE
1009
- * OFF SWITCH. `ACUVO_PROVIDER_ORDER=''` is how somebody says "no pin, give me
1010
- * the lottery back" — for a routing experiment, or because a provider is
1011
- * having a bad day. A first version of this used `??`, so an explicit empty
1012
- * string fell through to the default and the variable could not turn the
1013
- * feature off at all.
1014
- *
1015
- * ⚠️ AND MY OWN TEST ASSERTED THAT PROPERTY AND MISSED IT, because it
1016
- * reimplemented this expression locally instead of calling `callModel`. A test
1017
- * that copies the logic it is checking verifies the copy.
1018
- */
1019
- /**
1020
- * ⚠️ RESOLVED PER MODEL. A single global name was only ever correct for the
1021
- * model it was measured on: `StreamLake` does not serve pro at all, so every
1022
- * pro run asked for a provider that could not answer, matched nothing, and
1023
- * routed freely to GMICloud at 2.8x the tokens and 28x the cache reads.
1024
- * Measured: pro was served by GMICloud on 13 of 13 bench runs. See
1025
- * `PROVIDER_PIN_BY_MODEL`.
1026
- */
1027
- const explicitRaw = env?.ACUVO_PROVIDER_ORDER;
1028
- const hasExplicit = explicitRaw !== undefined && explicitRaw !== null;
1029
- const resolvedPin = providerOrderFor(model, env);
1030
- const providerOrder = resolvedPin.order;
1031
- const pinWasChosen = resolvedPin.source === 'env';
1032
-
1033
- /**
1034
- * ── ⚠️ THE HARD PIN, FOR PEOPLE WHO GENUINELY WANT ONE ─────────────────────
1035
- * `allow_fallbacks:false` turns an unhonourable pin into an HTTP 404 instead
1036
- * of a silent re-route. That is the RIGHT answer for a benchmark or a cache
1037
- * experiment and the WRONG default for a tool people work in: "never single"
1038
- * is this package's standing rule, and a cheaper request that does not happen
1039
- * is not cheaper. Opt-in, off unless `ACUVO_PROVIDER_STRICT` is truthy, and
1040
- * meaningless without a pin to be strict about.
1041
- */
1042
- /**
1043
- * ⚠️ `pinWasChosen`, NOT `providerOrder.length` — see the note above. Since the
1044
- * default pin arrived, the length test would let `ACUVO_PROVIDER_STRICT=1`
1045
- * alone harden a provider the user never named into a single point of failure.
1046
- * Strict is only ever strict about a pin a human typed.
1047
- */
1048
- const strictPin = pinWasChosen
1049
- && /^(1|true|yes|on)$/i.test(String(env?.ACUVO_PROVIDER_STRICT ?? '').trim());
1050
-
1051
- /**
1052
- * ── ⭐⭐ WHAT WE ASKED FOR TRAVELS BACK WITH WHAT WE GOT ────────────────────
1053
- *
1054
- * ⚠️ WITHOUT THIS THE COMPARISON IS IMPOSSIBLE ANYWHERE ELSE. `turn.mjs` sees
1055
- * a reply, not an environment: it can be told which upstream served the round
1056
- * (`provider`, off the response) but it has no way to know which one was
1057
- * REQUESTED, and "served by Decart" is only a finding next to "we asked for
1058
- * DeepInfra". Reading `process.env` again in the loop would be the wrong fix —
1059
- * `env` is injected here precisely so a library caller can set it per call,
1060
- * and a second reader would disagree with this one the first time anybody did.
1061
- *
1062
- * `null`, never `[]`, when nothing was pinned: an empty array reads as "a pin
1063
- * that matched nothing", which is the opposite of "no pin".
1064
- */
1065
- /**
1066
- * ⚠️ THE OVERRIDE WINS, AND ONLY IT MAY BE STRICT WITHOUT `ACUVO_PROVIDER_STRICT`.
1067
- * It carries a provider we watched serve this session, so it is known-reachable
1068
- * — the property a configured name cannot promise (pro's pin starts with
1069
- * `DeepSeek`, which 404s for this account).
1070
- */
1071
- const overrideOrder = Array.isArray(routeOverride?.order) ? routeOverride.order.filter(Boolean) : [];
1072
- const useOverride = overrideOrder.length > 0;
1073
- const effectiveOrder = useOverride ? overrideOrder : providerOrder;
1074
- const effectiveStrict = useOverride ? Boolean(routeOverride.strict) : strictPin;
1075
-
1076
- const providerPin = effectiveOrder.length > 0 ? [...effectiveOrder] : null;
1077
-
1078
- /**
1079
- * ── ⚠⚠⚠ WARM FIRST, THEN FALL BACK — THE 90% CACHE FLOOR ─────────────
1080
- *
1081
- * Roman, 2026-08-19: *"that caching needs to be 90 … you've said it
1082
- * permanently is, yet it isn't."* He is right, and this is the cause.
1083
- *
1084
- * ⚠️ MEASURED ACROSS 90 REAL RUNS from our own audit ledger: token-weighted
1085
- * hit rate **51.2%**, only 18 of 90 runs at or above 90%, and round 1
1086
- * non-zero on just 3 of 16. The prompt is NOT the problem — the system
1087
- * message is byte-identical across processes (3,671 chars, shared prefix
1088
- * 3,671/3,671). **The routing is.**
1089
- *
1090
- * `provider.order` is a PREFERENCE over several upstreams and
1091
- * `allow_fallbacks: true` lets OpenRouter pick freely. A prompt cache lives on
1092
- * exactly ONE upstream, so a fresh process lands wherever and starts cold.
1093
- * With round 1 cold, an N-round run cannot exceed (n−1)/n — a 3-round task is
1094
- * capped at 67% however perfect the prompt is. That is why "90% always" was
1095
- * arithmetically impossible, not merely unmet.
1096
- *
1097
- * ⭐ THE FIX IS NOT THE TRADE IT LOOKS LIKE. "Never single" rightly refuses a
1098
- * bare `allow_fallbacks: false`, because one provider having a bad ten minutes
1099
- * would be an outage for every user at once. So we do BOTH, in order:
1100
- *
1101
- * attempt 1 one provider, `allow_fallbacks: false` → lands on the warm cache
1102
- * attempt 2 the full order, fallbacks on → only if attempt 1 fails
1103
- *
1104
- * The lock is what buys the cache; the retry is what keeps "never single"
1105
- * true. **Neither half is optional** — shipping the lock alone would be a real
1106
- * availability regression, and shipping the retry alone changes nothing.
1107
- *
1108
- * ⚠️ AND IT RETRIES ONLY ON AVAILABILITY FAILURES. A 401, 402 or 404 is not
1109
- * the provider being busy — it is the key, the balance, or the model id, and
1110
- * every one of those fails identically on the second attempt. Retrying them
1111
- * would double the latency of the most common real errors and spend money to
1112
- * learn nothing.
1113
- */
1114
- /**
1115
- * ── ⭐⭐⭐ THE WARM ATTEMPT USES `only`, NOT `order` — AND THAT IS THE FIX ──
1116
- *
1117
- * Both restrict the request to one provider. Only one of them switches off
1118
- * OpenRouter's sticky routing, which is the feature that pins the SERVER
1119
- * rather than the company:
1120
- *
1121
- * order: ['StreamLake'], allow_fallbacks: false
1122
- * -> "your explicit ordering takes priority", sticky routing OFF,
1123
- * every cold process rolls the dice inside the fleet. Measured:
1124
- * 65 / 98 / 31 / 98.
1125
- * only: ['StreamLake']
1126
- * -> the same one-provider restriction, expressed as a WHITELIST. There
1127
- * is no ordering to take priority over, so nothing documented turns
1128
- * stickiness off.
1129
- *
1130
- * ⚠️ HOW SURE I AM, EXACTLY: that `order` disables sticky routing is quoted
1131
- * verbatim from their docs and confirmed by a second source. That `only`
1132
- * PRESERVES it is an inference — their docs do not discuss `only` at all, and
1133
- * I will not write that they do. It is the right change anyway because it is
1134
- * weakly dominant: identical restriction, and either stickiness survives (a
1135
- * large win) or it does not (exactly today's behaviour).
1136
- *
1137
- * ⚠️ THE SECOND ATTEMPT KEEPS `order`, deliberately. It exists to survive the
1138
- * first provider being unavailable, and there ORDERING IS THE POINT — try
1139
- * these, in this sequence. Losing stickiness on a leg that only runs when the
1140
- * warm machine already failed costs nothing that was not already lost.
1141
- */
1142
- /**
1143
- * ── ⚠️ AND THE ONE-NAME CASE, WHICH THE FIRST VERSION OF THIS FIX MISSED ──
1144
- *
1145
- * `warmFirst` required `effectiveOrder.length > 1`, so a pin of a SINGLE
1146
- * provider — including `ACUVO_PROVIDER_ORDER='Novita'`, and
1147
- * `provider-routing-visibility.test.mjs` asserts the default is deliberately
1148
- * *"one preferred provider, not a list pretending to be a policy"* — fell to
1149
- * the bottom branch and shipped `order` + `allow_fallbacks: true`.
1150
- *
1151
- * ⭐ THAT IS THE WORST OF BOTH. A manual order disables sticky routing, and
1152
- * `allow_fallbacks: true` means the request can land anywhere anyway. So the
1153
- * configuration that has ALREADY DECIDED which provider it wants was the one
1154
- * getting neither the pin nor the stickiness. One name now takes the same
1155
- * warm-then-fall-back path as several.
1156
- */
1157
- const warmFirst = effectiveOrder.length > 0 && !effectiveStrict && retryOnPinFailure !== false;
1158
- const attempts = warmFirst
1159
- ? [
1160
- { only: [effectiveOrder[0]] },
1161
- { order: effectiveOrder, allowFallbacks: true },
1162
- ]
1163
- /**
1164
- * ⭐ STRICT IS `only` TOO, and it is the truest expression of it: a
1165
- * whitelist cannot be left, so an unavailable upstream is a 404 rather than
1166
- * a silent re-route — exactly what strict was always asking for, now said
1167
- * in the vocabulary that keeps stickiness.
1168
- *
1169
- * ⚠️ ONE REAL TRADE, STATED: with several names, `only` drops the
1170
- * PREFERENCE between them. Strict callers pin one name in practice, and the
1171
- * alternative is keeping an ordering that switches off the server pinning
1172
- * this whole change exists for.
1173
- */
1174
- : effectiveStrict && effectiveOrder.length > 0
1175
- ? [{ only: effectiveOrder }]
1176
- : [{ order: effectiveOrder, allowFallbacks: !effectiveStrict }];
1177
-
1178
- const payload = {
1179
- model,
1180
- messages,
1181
- tools,
1182
- ...(effectiveOrder.length > 0
1183
- ? { provider: { order: effectiveOrder, allow_fallbacks: !effectiveStrict } }
1184
- : {}),
1185
- ...(streaming ? { stream: true } : {}),
1186
- /**
1187
- * The sticky key. See `sessionId` above for why this is the whole caching
1188
- * story and not a nicety. Omitted entirely when absent so no existing
1189
- * caller's wire body changes by one byte.
1190
- *
1191
- * WARNING: it is also OpenRouter's grouping key on the Logs page, so the
1192
- * value must identify a CONVERSATION, never a user or a tenant — a shared
1193
- * value would pin unrelated traffic to one machine and pool the logs of
1194
- * people who have nothing to do with each other.
1195
- */
1196
- ...(sessionId ? { session_id: String(sessionId).slice(0, 256) } : {}),
1197
- tool_choice: 'auto',
1198
- /**
1199
- * ⚠️ THE FIELD THAT DECIDES WHETHER A MULTI-FILE TASK IS POSSIBLE AT ALL.
1200
- * In a one-round turn, "one tool call per response" means one FILE per
1201
- * command — measured 2026-08-09: asked for a module plus its test, the model
1202
- * wrote the module, said it was writing both, and there was no second round
1203
- * to finish in. A direct probe of the same model DID return two calls, so
1204
- * the capability is there and OpenRouter's upstream routing (Baidu vs
1205
- * StreamLake on the same model id) is what varies.
1206
- *
1207
- * ⭐ CORRECTED 2026-08-09 — AND THE ORIGINAL NOTE WAS BLAMING THE WRONG
1208
- * THING. It read: "this model writes ONE file per response regardless of how
1209
- * many it promises… parallel_tool_calls did not fix it." That was measured on
1210
- * `deepseek-v3.2`, and it is NOT true of `deepseek-v4-flash-0731`.
1211
- *
1212
- * Re-measured on v4 with reasoning disabled: asked for three files
1213
- * (src/add.js, src/sub.js, src/index.js re-exporting both) it wrote **all
1214
- * three in one turn**, correctly, for $0.000110 — and the result runs:
1215
- * add(2,3)=5, sub(9,4)=5.
1216
- *
1217
- * ⚠️ THE LESSON IS ABOUT THE NOTE, NOT THE MODEL. A limitation was recorded
1218
- * against "this model" when it belonged to one specific version, and it then
1219
- * read as a permanent ceiling — the kind of stale pessimism that stops people
1220
- * retrying something that already works. Version the claim or do not make it.
1221
- */
1222
- parallel_tool_calls: true,
1223
- /**
1224
- * ── ⚠️⚠️ WITHOUT THIS, v4 AND qwen3.7 RETURN NOTHING AT ALL ──────────────
1225
- * Measured 2026-08-09 across three models in one day. `deepseek-v4-*` and
1226
- * `qwen3.7-*` ship a native reasoning budget ON BY DEFAULT and will spend
1227
- * the whole completion allowance thinking, returning
1228
- * `choices[0].message.content: null` — a billed HTTP 200 with no answer and
1229
- * no tool calls, which this CLI would report as "the model wrote nothing".
1230
- *
1231
- * ⚠️ It is NOT a small-budget problem: the codegen bake-off gave v4 9,000
1232
- * tokens and still got 0 bytes back. Capping reasoning EFFORT does not help;
1233
- * only switching it off does.
1234
- *
1235
- * On identical input, off measured 1.7x cheaper AND 1.9x faster AND the only
1236
- * setting that produces output. The console applies the same rule in
1237
- * `lib/llm.ts` (REASONING_ON_BY_DEFAULT) — ⚠️ two copies of one fact, which
1238
- * is a real debt: the shared transport this package still lacks is where it
1239
- * belongs.
1240
- */
1241
- ...(/(qwen3\.7|deepseek-v4)/i.test(model) ? { reasoning: { enabled: false } } : {}),
1242
- max_tokens: maxTokens,
1243
- temperature: 0.2,
1244
- // Asking for usage accounting is free and is the only way the cost line
1245
- // below is a measurement rather than an estimate.
1246
- usage: { include: true },
1247
- };
1248
-
1249
- /**
1250
- * ⚠️ 401/402/404 ARE NOT AVAILABILITY. A bad key, an empty balance or a wrong
1251
- * model id fails identically on every provider, so a second attempt costs
1252
- * latency and teaches nothing. Everything else — transport, 5xx, 429 — is the
1253
- * pinned upstream being unreachable or busy, which is what the fallback is for.
1254
- */
1255
- const worthFallingBackFrom = (status) => status !== 401 && status !== 402 && status !== 404;
1256
-
1257
- /**
1258
- * ⭐ DIRECT BEATS THE LOTTERY. When a DeepSeek key is present and the model is
1259
- * one we can name on their API, the whole provider-order dance is skipped:
1260
- * one vendor, one endpoint, their own context cache, no aggregator picking a
1261
- * server. `attempts` is collapsed to a single unpinned call because there is
1262
- * nothing left to pin — and no fallback, because falling back to OpenRouter
1263
- * mid-run would land on a cold machine and undo the reason we came here.
1264
- */
1265
- /**
1266
- * ── ⚠️⚠️⚠️ AND THE TRAP THAT MEASURING THE KEY EXPOSED, 2026-08-19 ────────
1267
- *
1268
- * The paragraph above ended *"and no fallback, because falling back to
1269
- * OpenRouter mid-run would land on a cold machine and undo the reason we came
1270
- * here."* That is correct about the CACHE and catastrophic about AVAILABILITY,
1271
- * and the live probe is what showed it:
1272
- *
1273
- * POST api.deepseek.com/chat/completions
1274
- * → 402 {"message":"Insufficient Balance"}
1275
- *
1276
- * The key in the repo is VALID and has NO MONEY. Combined with
1277
- * `worthFallingBackFrom` — which excludes 401/402/404 on the stated grounds
1278
- * that *"a bad key, an empty balance or a wrong model id fails identically on
1279
- * every provider"* — that made every single call fail hard with no second
1280
- * attempt, the moment anyone exported the key.
1281
- *
1282
- * ⭐ THAT PREMISE IS TRUE FOR ONE ACCOUNT AND FALSE FOR TWO. It was written
1283
- * when every attempt was a different PROVIDER ORDER on one OpenRouter key, so
1284
- * an empty balance really was the same fact each time. A direct vendor call
1285
- * uses a DIFFERENT VENDOR, a DIFFERENT KEY and a DIFFERENT BALANCE, and
1286
- * DeepSeek being out of credit says precisely nothing about OpenRouter. The
1287
- * rule did not change; the world it described did.
1288
- *
1289
- * ⚠️ VERIFIED NOT LIVE TODAY: `DEEPSEEK_API_KEY` is absent from the Vercel
1290
- * environment and from the shell, so nothing is broken in production right
1291
- * now. It is a loaded trap, not a fire — and the fix belongs in before the
1292
- * top-up that arms it, not after.
1293
- *
1294
- * ⭐ So the routes are now heterogeneous: the direct vendor FIRST (for the
1295
- * cache), then the OpenRouter ladder behind it (for availability). A cold
1296
- * answer beats no answer. The cache argument only ever applied to a call that
1297
- * SUCCEEDED, and this fallback fires only when one did not.
1298
- */
1299
- const direct = directDeepSeek(model, env);
1300
- const openRouterRoutes = attempts.map((a) => ({
1301
- endpoint: resolveApiUrl(), key: apiKey, model,
1302
- order: a.order ?? null, only: a.only ?? null, allowFallbacks: a.allowFallbacks, direct: false,
1303
- }));
1304
- const routes = direct
1305
- ? [{ endpoint: direct.url, key: direct.apiKey, model: direct.model, order: null, only: null, allowFallbacks: true, direct: true }, ...openRouterRoutes]
1306
- : openRouterRoutes;
1307
-
1308
- let res = null;
1309
- let transportFail = null;
1310
-
1311
- for (let i = 0; i < routes.length; i += 1) {
1312
- const attempt = routes[i];
1313
- const isLast = i === routes.length - 1;
1314
- const endpoint = attempt.endpoint;
1315
- const authKey = attempt.key;
1316
- const wireModel = attempt.model;
1317
- /**
1318
- * The three shapes this can take, and they are not interchangeable:
1319
- * only -> one provider, whitelist, sticky routing left alone (the warm leg)
1320
- * order -> a sequence to try, sticky routing off by OpenRouter's rule
1321
- * none -> a direct vendor call, which has no provider concept at all
1322
- */
1323
- const body = {
1324
- ...payload,
1325
- model: wireModel,
1326
- ...(attempt.only
1327
- ? { provider: { only: attempt.only } }
1328
- : (attempt.order && attempt.order.length > 0
1329
- ? { provider: { order: attempt.order, allow_fallbacks: attempt.allowFallbacks } }
1330
- : {})),
1331
- };
1332
- /**
1333
- * ⚠⚠ `provider` IS AN OPENROUTER FIELD AND MUST NOT REACH DEEPSEEK.
1334
- * The base payload carries it, and the per-attempt spread only OVERRIDES it
1335
- * — it cannot remove it. So a direct call was shipping
1336
- * `provider: { order: ['StreamLake', ...] }` to an API that has never heard
1337
- * of StreamLake. Caught by printing the wire body rather than trusting the
1338
- * branch, and it is the kind of thing that 400s in production and reads as
1339
- * "DeepSeek is down".
1340
- *
1341
- * ⚠️ `attempt.direct`, NOT `direct`. Once the routes became heterogeneous
1342
- * this had to become per-route: `direct` is now true for the whole CALL
1343
- * whenever a DeepSeek key exists, so testing it here would strip the
1344
- * `provider` pin off every OpenRouter fallback — silently unpinning the
1345
- * ladder and handing us back the routing lottery we went direct to escape.
1346
- * The same mistake in the opposite direction as the bug this comment is
1347
- * about, made by the fix for it.
1348
- */
1349
- if (attempt.direct) delete body.provider;
1350
-
1351
- let attemptRes = null;
1352
- try {
1353
- attemptRes = await fetchImpl(endpoint, {
1354
- method: 'POST',
1355
- headers: {
1356
- Authorization: `Bearer ${authKey}`,
1357
- 'Content-Type': 'application/json',
1358
- // OpenRouter uses these for attribution; harmless and polite.
1359
- 'HTTP-Referer': 'https://acuvo.xxiautomate.com',
1360
- 'X-Title': 'Acuvo Code',
1361
- },
1362
- body: JSON.stringify(body),
1363
- signal: AbortSignal.timeout(timeoutMs),
1364
- });
1365
- } catch (err) {
1366
- transportFail = { ok: false, error: describeTransportError(err, timeoutMs), kind: transportErrorKind(err) };
1367
- if (isLast) return transportFail;
1368
- continue;
1369
- }
1370
-
1371
- /**
1372
- * ⭐ A non-ok status falls through to the existing handler below on the LAST
1373
- * attempt, or on any status a second provider could not fix.
1374
- *
1375
- * ⚠️ EXCEPT FROM THE DIRECT ROUTE, WHERE EVERY FAILURE IS WORTH LEAVING.
1376
- * `worthFallingBackFrom` excludes 401/402/404 because on one account those
1377
- * fail identically however many times you ask. Leaving the DIRECT vendor is
1378
- * not asking again — it is asking a DIFFERENT COMPANY with a different key
1379
- * and a different balance. `Insufficient Balance` at DeepSeek is the single
1380
- * most likely failure here (it is what the live probe returned) and it is
1381
- * exactly the one the shared predicate would refuse to escape.
1382
- */
1383
- const canLeave = attempt.direct ? true : worthFallingBackFrom(attemptRes.status);
1384
- if (attemptRes.ok || isLast || !canLeave) { res = attemptRes; break; }
1385
- }
1386
-
1387
- if (!res) return transportFail ?? { ok: false, error: 'the model call produced no response' };
1388
-
1389
- if (!res.ok) {
1390
- const text = await res.text().catch(() => '');
1391
- /**
1392
- * ── ⚠️⚠️ `providerPin` TRAVELS ON THE FAILURE PATH TOO, AND THAT IS THE POINT ─
1393
- *
1394
- * It was returned on all three SUCCESS returns and none of the failures, so
1395
- * the one caller that needs it most could never see it: `callChain` decides
1396
- * whether a failure is about THIS MODEL or about the REQUEST, and a 404
1397
- * caused by a bad PIN reads exactly like a 404 caused by a dead model id —
1398
- * "No endpoints found for <model>", naming the model that was never the
1399
- * problem.
1400
- *
1401
- * ⭐ Measured 2026-08-14: with the pin invisible here, `isModelSpecific`
1402
- * matched, and the chain spent all four candidates re-sending the identical
1403
- * bad pin — four round trips and four times the wait to learn one fact
1404
- * about an environment variable. The chain cannot reason about a cause it
1405
- * is never told.
1406
- */
1407
- return { ok: false, error: classifyHttpFailure(res.status, text, { pin: providerPin }), providerPin };
1408
- }
1409
-
1410
- /**
1411
- * ⚠️ STREAMED AND WHOLE RESPONSES DIVERGE HERE AND NOWHERE ELSE. Both paths
1412
- * produce the identical reply shape, so the loop, the summary and the chain
1413
- * never learn which one ran.
1414
- */
1415
- if (streaming) {
1416
- /**
1417
- * ── ⚠️ `!res.body` WAS A GATE THAT COULD NEVER OPEN ────────────────────────
1418
- * undici populates `res.body` on EVERY body-bearing response, so the
1419
- * whole-body fallback below was dead code for as long as it existed — and
1420
- * the comment above it confidently described behaviour that never ran.
1421
- * Measured 2026-08-10: a textbook 200 carrying
1422
- * `{"choices":[{"message":{"content":"ok"}}]}` was fed to the SSE parser,
1423
- * which found no `data:` lines and reported "the stream closed without
1424
- * sending anything". That phrase is not in `isRetryable`'s vocabulary, so the
1425
- * chain STOPPED — on a completion that was correct and already billed.
1426
- *
1427
- * ⚠️ THE `ct &&` GUARD IS NOT DEFENSIVE PADDING. A stub or a proxy that sends
1428
- * no content-type tells us nothing, and inferring "then it must be JSON"
1429
- * would break real streaming on the strength of a missing header. No header
1430
- * = today's behaviour, unchanged.
1431
- */
1432
- const ct = (typeof res.headers?.get === 'function' ? res.headers.get('content-type') : '') || '';
1433
- if (!res.body || (ct && !/text\/event-stream/i.test(ct))) {
1434
- // ⚠️ Not an error — a provider that ignored `stream:true` and sent JSON.
1435
- // Falling through to the whole-body path is more useful than failing.
1436
- let whole;
1437
- try {
1438
- whole = await res.json();
1439
- } catch (err) {
1440
- /**
1441
- * ⚠️ THE OLD `.catch(() => null)` FLATTENED TWO DIFFERENT WORLDS. A body
1442
- * that is not JSON is the provider's fault and retrying will produce the
1443
- * same thing; a body that ABORTED halfway is the network's fault and the
1444
- * next provider will very likely work. Reporting both as "neither a
1445
- * stream nor JSON" made the second one non-retryable.
1446
- */
1447
- if (err?.name === 'SyntaxError') {
1448
- return { ok: false, error: 'the provider returned a 200 whose body is neither an event stream nor JSON.' };
1449
- }
1450
- return { ok: false, error: describeTransportError(err, timeoutMs), kind: transportErrorKind(err) };
1451
- }
1452
- if (!whole) return { ok: false, error: 'the provider returned neither a stream nor JSON.' };
1453
- const r = extractReply(whole);
1454
- return r.ok ? { ok: true, ...r, model, providerPin } : { ok: false, error: r.error, providerPin };
1455
- }
1456
- /**
1457
- * ── ⚠️⚠️ THIS AWAIT USED TO BE THE ONE THAT ENDED THE SESSION ─────────────
1458
- * A dropped socket mid-stream surfaces from undici as `TypeError:
1459
- * terminated`. Uncaught here it escaped past turn.mjs's deliberate "a
1460
- * mid-loop model failure is not a whole-session failure" handling, out of
1461
- * main(), and printed "acuvo crashed — this is a bug in acuvo-code". Exit 1,
1462
- * and under `--json` stdout was EMPTY — so the whole summary went with it,
1463
- * including the file round 1 had already written to disk, and no fallback in
1464
- * the chain was ever tried. `callModel`'s contract is that it never throws;
1465
- * only the fetch honoured it.
1466
- */
1467
- let collected;
1468
- try {
1469
- collected = await collectStream(res.body, { onText });
1470
- } catch (err) {
1471
- return { ok: false, error: describeTransportError(err, timeoutMs), kind: transportErrorKind(err) };
1472
- }
1473
- if (!collected.ok) return { ok: false, error: collected.error };
1474
- return { ok: true, ...collected, model, providerPin };
1475
- }
1476
-
1477
- let body;
1478
- try {
1479
- body = await res.json();
1480
- } catch (err) {
1481
- // ⚠️ Same trap as the streaming branch above, and it was here too: a body
1482
- // that aborted mid-read is a TRANSPORT fault, and calling it "not JSON"
1483
- // told the chain not to bother with the next provider.
1484
- if (err?.name !== 'SyntaxError') return { ok: false, error: describeTransportError(err, timeoutMs), kind: transportErrorKind(err) };
1485
- return { ok: false, error: 'OpenRouter returned a 200 with a body that is not JSON.' };
1486
- }
1487
- const reply = extractReply(body);
1488
- if (!reply.ok) return { ok: false, error: reply.error };
1489
- return { ok: true, ...reply, model, providerPin };
1490
- }
1
+ /**
2
+ * THE MODEL CALL — one provider, one round-trip, and a failure that says what
3
+ * to do about it.
4
+ *
5
+ * ── WHY THIS IS NOT `console/lib/llm.ts` ────────────────────────────────────
6
+ * The console's transport is the right thing for the console: a four-provider
7
+ * chain with rate-limit-aware reordering, prompt-cache annotation, tier gates
8
+ * and a meter. It is also TypeScript that imports `@/lib/codegen-cost` →
9
+ * `@/lib/plan-catalog` → the Next path alias, and pulling it in here would drag
10
+ * a Next/TS build into a package whose entire point is `node acuvo.mjs` with
11
+ * zero install. Re-implementing 700 lines of chain logic would be the fork this
12
+ * architecture forbids; calling ONE endpoint with the same message shape is not
13
+ * a fork, it is the second client.
14
+ *
15
+ * ⚠️ SO THE DEBT IS NAMED RATHER THAN HIDDEN: this client is SINGLE-PROVIDER,
16
+ * which breaks the house rule "never single". That is acceptable for a local
17
+ * developer tool where the failure mode is "the command exits with a message
18
+ * you can read" — and unacceptable the moment this path serves a customer. The
19
+ * fix when it matters is to extract the console's chain into a dependency-free
20
+ * `.mjs` both clients import, not to grow a second chain here.
21
+ *
22
+ * ── THE FAILURE MESSAGE IS THE FEATURE ──────────────────────────────────────
23
+ * A coding agent that hangs, or dies on `Cannot read properties of undefined`,
24
+ * is worse than one that does not exist — you cannot tell a broken key from a
25
+ * broken tool from a broken network. Every exit from here is a sentence naming
26
+ * the cause and the next action, and `classifyHttpFailure` is pure so the whole
27
+ * table is testable without spending a cent.
28
+ */
29
+
30
+ import { TOOL_SCHEMAS } from './tools.mjs';
31
+ import { collectStream } from './stream.mjs';
32
+ import { resolveCredential } from './account.mjs';
33
+
34
+
35
+ /**
36
+ * ⭐ v4-flash, measured 2026-08-09 against v3.2-exp on an identical brief:
37
+ * $0.000842 vs $0.001465 (1.7x cheaper), 50s vs 95s (1.9x faster), and it
38
+ * emitted a correctly SIZED svg icon where v3.2-exp emitted none. Reasoning must
39
+ * be off — see the request body below.
40
+ */
41
+ export const DEFAULT_MODEL = 'deepseek/deepseek-v4-flash-0731';
42
+ export const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
43
+
44
+ /**
45
+ * ── ⭐⭐⭐ DIRECT TO DEEPSEEK — THE ONLY WAY THE CACHE IS RELIABLE ─────────
46
+ *
47
+ * Roman, 2026-08-19: *"the caching still isn't 90 percent … if it's not 90 our
48
+ * product is gone."* He is right, and this is why it was not.
49
+ *
50
+ * ⚠⚠ MEASURED, and it is not prefix drift. **99.9% of the prompt is
51
+ * byte-identical across two completely different tasks** — the tools JSON alone
52
+ * is 60,799 chars, 92% of the payload, and never changes. The ceiling is 99.9%.
53
+ *
54
+ * What actually happens on OpenRouter, measured over four consecutive real runs:
55
+ *
56
+ * run 1 cache 65% round 1 0%
57
+ * run 2 cache 98% round 1 98%
58
+ * run 3 cache 31% round 1 0%
59
+ * run 4 cache 98% round 1 98%
60
+ *
61
+ * and on the SAME task three times: 0% → 79% → 99%.
62
+ *
63
+ * ⭐ THAT IS A ROUTING LOTTERY, NOT A CACHE PROBLEM. A prompt cache lives on ONE
64
+ * SERVER. `provider: { order: ['StreamLake'], allow_fallbacks: false }` pins the
65
+ * PROVIDER — and StreamLake is a fleet. Pinning the provider does not pin the
66
+ * machine, so each run rolls the dice and warms whichever server it landed on.
67
+ * No amount of prefix discipline can fix that from our side.
68
+ *
69
+ * ⚠️ Going direct removes the lottery entirely: one vendor, one endpoint, their
70
+ * own automatic context caching, and no aggregator choosing a server for us. It
71
+ * is also cheaper, because OpenRouter's margin disappears with it.
72
+ *
73
+ * ⚠️ OFF UNLESS `DEEPSEEK_API_KEY` IS SET. No key, no behaviour change — this
74
+ * cannot silently re-route anyone's traffic or spend on an account they did not
75
+ * choose.
76
+ */
77
+ export const DEEPSEEK_URL = 'https://api.deepseek.com/chat/completions';
78
+
79
+ /**
80
+ * DeepSeek's own model ids differ from the aggregator's slugs. Mapping only what
81
+ * we actually pin; an unmapped model falls through to OpenRouter rather than
82
+ * being guessed at, because a wrong id is a 404 that reads like an outage.
83
+ */
84
+ export const DEEPSEEK_DIRECT_MODELS = Object.freeze({
85
+ 'deepseek/deepseek-v4-flash-0731': 'deepseek-chat',
86
+ 'deepseek/deepseek-v4-pro-0813': 'deepseek-reasoner',
87
+ });
88
+
89
+ /**
90
+ * Can this call go direct? Requires a key AND a model we can name on their API.
91
+ * @returns {{ url: string, apiKey: string, model: string } | null}
92
+ */
93
+ export function directDeepSeek(model, env = process.env) {
94
+ /**
95
+ * ── ⭐⭐⭐ OFF UNLESS EXPLICITLY ASKED FOR (Roman, 2026-08-22) ──────────────
96
+ *
97
+ * *"no direct deepseek api, we can just use the rest of it for testing."*
98
+ *
99
+ * ⚠️ A KEY BEING PRESENT IS NOT A REQUEST TO USE IT. Before this line, merely
100
+ * exporting `DEEPSEEK_API_KEY` silently re-routed every build onto the direct
101
+ * endpoint — which is 3.7x dearer on OUTPUT ($0.66/M vs OpenRouter's $0.18/M)
102
+ * and doubles for 7 hours a day under DeepSeek's peak billing (01:00-04:00 and
103
+ * 06:00-10:00 UTC = 11am-2pm / 4pm-8pm AEST). Measured over 95M tokens that is
104
+ * 62.3% margin against 85.6%.
105
+ *
106
+ * ⭐ Direct's cache READ is genuinely cheaper ($0.007/M vs $0.0154/M) and that
107
+ * is why it once led. It cannot pay for the cache MISSES (2.9x dearer) or the
108
+ * output (3.7x dearer), and output is ~60% of the bill.
109
+ *
110
+ * Mirrors `deepSeekDirectEnabled()` in `console/lib/llm.ts` — the builder and
111
+ * the CLI must not disagree about which vendor serves a build.
112
+ */
113
+ if (String(env?.ACUVO_DEEPSEEK_DIRECT ?? '') !== '1') return null;
114
+ const key = String(env?.DEEPSEEK_API_KEY ?? '').trim();
115
+ if (!key) return null;
116
+ const mapped = DEEPSEEK_DIRECT_MODELS[String(model ?? '')];
117
+ if (!mapped) return null;
118
+ return { url: DEEPSEEK_URL, apiKey: key, model: mapped };
119
+ }
120
+
121
+ /**
122
+ * ── ⚠️⭐ A TEST SEAM THAT CANNOT BECOME AN EXFILTRATION CHANNEL ─────────────
123
+ *
124
+ * The whole SUCCESS path of this CLI — the report, the change list, the inline
125
+ * image rendering — was untestable, because every test drives `bin` with a dead
126
+ * key and stops at the refusal. A `ReferenceError` on that path shipped and
127
+ * 1,413 green tests said nothing; only a live run found it.
128
+ *
129
+ * ⚠️ THE OBVIOUS FIX IS DANGEROUS. A plain `ACUVO_API_URL` override redirects
130
+ * where `Authorization: Bearer <the user's key>` is SENT. Anything that can set
131
+ * an environment variable could then quietly harvest the key, and "env access
132
+ * already implies code execution" is a bad excuse for handing it a ready-made
133
+ * exfiltration primitive with a documented name.
134
+ *
135
+ * ⭐ SO IT IS ACCEPTED ONLY FOR LOOPBACK. A test can point it at a server it
136
+ * just started on 127.0.0.1; nobody can point it anywhere the key would leave
137
+ * this machine. Non-loopback values are not silently ignored either — being
138
+ * ignored is how a misconfiguration turns into a mystery — they THROW.
139
+ */
140
+ export function resolveApiUrl(env = process.env) {
141
+ /**
142
+ * ── ⭐⭐ AN ACUVO ACCOUNT ROUTES THROUGH OUR GATEWAY, AND ONLY AN ACCOUNT ──
143
+ *
144
+ * This is the line that makes "buy Acuvo credits, never see a provider key"
145
+ * true rather than aspirational, and it is deliberately HERE rather than in
146
+ * `callModel`'s signature: every caller — the chain, the refuter, the
147
+ * subagent, best-of, the vision leg — reaches the provider through this one
148
+ * function, so putting it here means there is no call site that can be
149
+ * forgotten. That is the defect class this package loses to most often.
150
+ *
151
+ * ⚠️ THE ORDER MATTERS AND IT IS NOT ALPHABETICAL. The account is consulted
152
+ * BEFORE `ACUVO_API_URL`, because `ACUVO_API_URL` is a loopback-only TEST
153
+ * SEAM whose whole justification is that it cannot send a credential off this
154
+ * machine. Letting it override a signed-in account would let anything that
155
+ * can set an environment variable redirect an authenticated session — the
156
+ * exact primitive that restriction exists to deny.
157
+ *
158
+ * ⚠️ AND BYOK IS NEVER ROUTED HERE. `resolveCredential` returns a null URL
159
+ * for a provider key, so a key the user brought is posted to the provider and
160
+ * to nobody else. A user's own credential arriving at our servers would be a
161
+ * betrayal of the plainest kind, and it is prevented by construction rather
162
+ * than by remembering.
163
+ */
164
+ const credential = resolveCredential(env);
165
+ if (credential.mode === 'account' && credential.url) return credential.url;
166
+
167
+ const raw = String(env?.ACUVO_API_URL ?? '').trim();
168
+ if (!raw) return OPENROUTER_URL;
169
+
170
+ let u;
171
+ try {
172
+ u = new URL(raw);
173
+ } catch {
174
+ throw new Error(`ACUVO_API_URL is not a URL: ${JSON.stringify(raw)}`);
175
+ }
176
+ const host = u.hostname.replace(/^\[|\]$/g, '');
177
+ const loopback = host === 'localhost' || host === '127.0.0.1' || host === '::1' || /^127\./.test(host);
178
+ if (!loopback) {
179
+ throw new Error(
180
+ `ACUVO_API_URL may only point at loopback (localhost / 127.0.0.1 / ::1); refusing ${u.hostname}. `
181
+ + 'This override exists so tests can drive the CLI against a local stub — it is not a way to route your '
182
+ + 'API key through another host.',
183
+ );
184
+ }
185
+ return u.toString();
186
+ }
187
+ /** One round-trip, generous: a coder model writing several whole files is slow,
188
+ * and a premature abort looks exactly like a hang to the person waiting. */
189
+ export const DEFAULT_TIMEOUT_MS = 180_000;
190
+ /**
191
+ * ── ⭐ RAISED 8,000 → 12,000 (2026-08-10), AND EVERY DIGIT IS MEASURED ───────
192
+ *
193
+ * ⚠️ THE OLD CEILING DID NOT PRODUCE A SHORT FILE — IT PRODUCED NO FILE. Asked
194
+ * for one large module at `max_tokens: 8000`, the completion was cut off INSIDE
195
+ * the `write_file` tool-call JSON and the run died on
196
+ * `tool arguments were not valid JSON: Unterminated string at position 27784`.
197
+ * Zero bytes written, $0.001522 billed, nothing to show for it. Note this is
198
+ * worse than the failure `report.mjs` warns about: the truncation lands in the
199
+ * arguments, so it surfaces as a REFUSAL, and the `finishReason === 'length'`
200
+ * hint ("re-run with --max-tokens higher") never gets to fire. The user is told
201
+ * the model emitted bad JSON, which sounds like a model defect rather than a
202
+ * budget they can raise.
203
+ *
204
+ * ── WHY 12,000 AND NOT A ROUNDER, BIGGER NUMBER ─────────────────────────────
205
+ * Measured completion density, twice, on the identical prompt: 27,784 chars of
206
+ * tool-argument at 8k and 54,086 at 16k — 3.47 and 3.38 chars/token, 3.29
207
+ * marginal. So the cost of re-emitting a whole file is `bytes / 3.29` tokens,
208
+ * and this package's OWN lib/ says what that has to cover:
209
+ *
210
+ * lib/git.mjs 24,110 B → ~7,328 tok fits 8k with 8% to spare
211
+ * lib/command.mjs 28,869 B → ~8,775 tok ✖ DID NOT FIT
212
+ * lib/policy.mjs 34,635 B → ~10,527 tok ✖ DID NOT FIT
213
+ * lib/turn.mjs 80,015 B → ~24,320 tok fits nothing sane
214
+ *
215
+ * At 8,000 this tool could not rewrite two of its own source files, and cleared
216
+ * a third by 8% — one added comment from failing. 12,000 covers policy.mjs (the
217
+ * largest plausible single rewrite) with ~14% headroom for the prose note and a
218
+ * second tool call in the same response. turn.mjs is deliberately NOT covered:
219
+ * sizing the default to re-emit 80KB would be sizing for exactly the case
220
+ * `edit_file` exists to prevent.
221
+ *
222
+ * ── ⚠️ THE UPPER BOUND IS THE TIMEOUT, NOT THE PRICE ────────────────────────
223
+ * Measured: a 16,000-token completion took 112s wall-clock. Against
224
+ * DEFAULT_TIMEOUT_MS = 180s that puts the real delivery limit near ~25,000
225
+ * tokens — above which the default would be advertising a ceiling the default
226
+ * timeout cannot pay for. 12,000 lands at ~85s, under half the budget.
227
+ *
228
+ * ── 💸 COST IMPACT ──────────────────────────────────────────────────────────
229
+ * `max_tokens` is a CEILING, billed only on tokens actually generated, so this
230
+ * is $0.00 on ordinary work — verified: two real fix-and-verify runs spent
231
+ * 11,746 and 16,258 tokens across 3-4 rounds TOTAL (prompt included) and never
232
+ * came near 8,000 in a single completion. The only spend that changes is the
233
+ * pathological one, where the worst case per round goes
234
+ * 8,000 × $0.18/M = $0.00144 → 12,000 × $0.18/M = $0.00216 (+$0.00072).
235
+ *
236
+ * ⚠️ AND THAT COST IS REAL, WHICH IS THE ARGUMENT AGAINST GOING HIGHER. The 16k
237
+ * probe above ALSO truncated — an unbounded request fills whatever ceiling you
238
+ * give it, so raising this does not "fix" such a task, it just doubles the bill
239
+ * for the same nothing ($0.001522 → $0.002917, measured). Raise to cover real
240
+ * files; do not raise to chase a request no ceiling satisfies.
241
+ */
242
+ export const DEFAULT_MAX_TOKENS = 12_000;
243
+
244
+ /**
245
+ * Read the model configuration out of the environment.
246
+ *
247
+ * ⚠️ THE DEFAULT MODEL IS THE CHEAP CODER, DELIBERATELY, and for the reason
248
+ * `console/lib/llm.ts` spells out at length: an unset env var must cost little
249
+ * and be slightly worse, never cost a lot and be slightly better. The first
250
+ * failure mode is visible in the output; the second is visible only on an
251
+ * invoice.
252
+ *
253
+ * ⚠️ AND NOTE THE DRIFT THAT IS REAL: the console defaults to
254
+ * `deepseek/deepseek-v3.2-exp` and prices that id in `codegen-cost.ts`. This
255
+ * defaults to `deepseek/deepseek-v3.2` (both exist on OpenRouter; the non-exp
256
+ * one is the stable release). They are two clients of one capability and they
257
+ * should eventually agree — recorded here rather than silently unified, because
258
+ * changing the console's priced default is a money decision, not a CLI one.
259
+ *
260
+ * @param {Record<string, string | undefined>} [env]
261
+ * @returns {{ apiKey: string, model: string, configured: boolean }}
262
+ */
263
+ /**
264
+ * ── ⭐⭐ AN ACUVO ACCOUNT COMES FIRST; A PROVIDER KEY STILL WORKS ────────────
265
+ *
266
+ * Acuvo Code is meant to work the way Claude Code does — you buy Acuvo credits
267
+ * and never see a provider key. This function used to read
268
+ * `OPENROUTER_API_KEY` out of the user's environment and nothing else, which is
269
+ * BYOK and was never the plan.
270
+ *
271
+ * ⚠️ BYOK IS KEPT, DELIBERATELY. Everyone using this today has that variable
272
+ * set; breaking them the day the gateway ships would be the worst possible
273
+ * introduction to it. So: an account is PREFERRED, a provider key still WORKS,
274
+ * and `mode` says which — because those are two different people's money and
275
+ * confusing them is unforgivable.
276
+ *
277
+ * ⭐ `gatewayUrl` IS NULL FOR BYOK, AND THAT IS THE SECURITY LINE. A provider
278
+ * key must never be posted anywhere except the provider. Only an ACUVO token —
279
+ * ours, scoped to one account, revocable by us — is ever sent to our gateway.
280
+ *
281
+ * ⚠️ With only `OPENROUTER_API_KEY` set, every field below is what it was
282
+ * before this change, so an existing setup is byte-identical.
283
+ *
284
+ * @param {Record<string, string | undefined>} [env]
285
+ * @returns {{ apiKey: string, model: string, configured: boolean,
286
+ * mode: 'account' | 'byok' | 'unconfigured', gatewayUrl: string | null,
287
+ * email: string | null }}
288
+ */
289
+ export function readModelConfig(env = process.env) {
290
+ const credential = resolveCredential(env);
291
+ const model = (env.OPENROUTER_CODEGEN_MODEL || '').trim() || DEFAULT_MODEL;
292
+ return {
293
+ apiKey: credential.token,
294
+ model,
295
+ configured: credential.token.length > 0,
296
+ mode: credential.mode,
297
+ gatewayUrl: credential.url,
298
+ email: credential.email,
299
+ };
300
+ }
301
+
302
+ /**
303
+ * ── ⚠️ THIS IS THE FIRST THING A NEW USER EVER SEES ─────────────────────────
304
+ *
305
+ * They installed it thirty seconds ago and typed a prompt. Whatever this says is
306
+ * their entire first impression, and it decides whether they go and get a key or
307
+ * close the terminal.
308
+ *
309
+ * ⚠️ THE PREVIOUS VERSION FAILED TWO WAYS, BOTH INVISIBLE FROM INSIDE THE
310
+ * MONOREPO:
311
+ * 1. It never said WHERE TO GET A KEY — it explained how to set a variable
312
+ * they do not have, answering the second question and skipping the first.
313
+ * 2. It suggested `node --env-file=console/.env.local …`, a path that exists
314
+ * only in OUR repository. To anyone else that is noise from a tool that has
315
+ * clearly never been installed anywhere.
316
+ *
317
+ * ⭐ Short, one link, one command that works, and the cost stated — because
318
+ * "is this going to charge me" is the real unspoken question, and the honest
319
+ * answer happens to be excellent.
320
+ */
321
+ /**
322
+ * ── ⚠️⚠️ IT OPENED BY ASKING FOR SOMEBODY ELSE'S PRODUCT ────────────────────
323
+ *
324
+ * The previous first line was "Acuvo Code needs an OpenRouter key to reach a
325
+ * model." A stranger's entire first impression was a demand for a competitor's
326
+ * credential, before a single word about what this thing is or why they should
327
+ * bother. A dogfood review put it plainly: the storefront sells someone else.
328
+ *
329
+ * ⭐ SO IT LEADS WITH THE ONE SENTENCE THAT IS ACTUALLY DIFFERENT. Every coding
330
+ * agent writes files. This is the only one that quotes the price first and stops
331
+ * at the number you gave it, and that is the fact worth spending line one on.
332
+ *
333
+ * ⭐ AND THE COST IS THE HOOK, NOT A FOOTNOTE. "Is this going to charge me" is
334
+ * the real unspoken question, and our honest answer happens to be excellent —
335
+ * so it is stated in dollars, with the default ceiling, which turns "how much
336
+ * might this cost me" into "two cents, worst case, and I chose it".
337
+ *
338
+ * ⚠️ IT DOES NOT PROMISE A PLAN. Acuvo Code is intended to be unlocked by an
339
+ * Acuvo plan, and that gateway does not exist yet. Writing marketing for a
340
+ * product that does not ship is how a first impression becomes a broken
341
+ * promise — so this describes exactly what is true today and nothing more.
342
+ * When the gateway ships, this message changes with it.
343
+ *
344
+ * ⚠️ `--doctor` IS NAMED, because it is the best thing we have for someone who
345
+ * is stuck: it needs no key, runs offline, and every line it prints names the
346
+ * variable that fixes it.
347
+ */
348
+ /**
349
+ * ── ⭐⭐⭐ THE GATEWAY SHIPPED, SO THIS MESSAGE CHANGED WITH IT (2026-08-22) ──
350
+ *
351
+ * The note above promised exactly that: *"When the gateway ships, this message
352
+ * changes with it."* It shipped — `acuvo --login` lands an Acuvo key, and the
353
+ * metered path recorded its first real usage row today after never once having
354
+ * worked.
355
+ *
356
+ * ⚠️⚠️ AND UNTIL THIS EDIT THE FRONT DOOR SOLD THE COMPETITION. The first thing
357
+ * a brand-new user saw was "create your own OpenRouter key" — BYOK, which Roman
358
+ * has ruled out twice, printed as step 1 of onboarding on a package anyone can
359
+ * now `npm i -g`. `--help` did list `--login`; the message people actually hit
360
+ * did not. Every stranger who installed this brought their own key, so we
361
+ * metered nothing and earned nothing.
362
+ *
363
+ * ⭐ BOTH PATHS STAY, ORDER REVERSED. BYOK is not removed — it is honest, it
364
+ * works, and hiding it would make the tool look locked. It is simply no longer
365
+ * the default answer to "how do I start".
366
+ *
367
+ * ⚠️ IT STILL PROMISES NOTHING THAT DOES NOT EXIST. No pricing, no "sign up
368
+ * free", no plan names — self-serve signup has never been walked end to end
369
+ * (every tenant today is operated · unmetered). It names the two commands that
370
+ * are real and stops there.
371
+ */
372
+ export const MISSING_KEY_MESSAGE = [
373
+ 'Acuvo Code — a terminal coding agent that tells you the price before it runs,',
374
+ 'stops at the number you set, and can re-check every claim it ever made.',
375
+ '',
376
+ 'It needs a key. Two ways — then run the same command again:',
377
+ '',
378
+ ' A) Your Acuvo account, billed to your Acuvo credits:',
379
+ ' acuvo --login (paste the key from Settings → API keys)',
380
+ '',
381
+ ' B) Your own key, billed to you — https://openrouter.ai/keys',
382
+ ' export OPENROUTER_API_KEY=sk-or-v1-... (bash / zsh)',
383
+ ' $env:OPENROUTER_API_KEY = "sk-or-v1-..." (PowerShell)',
384
+ '',
385
+ 'A typical task costs $0.001-$0.003. The ceiling is $0.02 a run unless you',
386
+ 'raise it, so a mistake costs two cents to find.',
387
+ '',
388
+ /**
389
+ * ⚠️ THE REMEDY MUST RUN ON THE PLATFORM IT IS PRINTED ON. This line was
390
+ * `node --env-file=.env "$(which acuvo)" "<prompt>"` for everybody — and
391
+ * `$(which acuvo)` is bash. A Windows user, who is exactly the person most
392
+ * likely to be reading a "no key" message, pastes it into PowerShell and gets
393
+ * a second error on top of the first. ⭐ A remedy that fails is worse than no
394
+ * remedy: it converts "I need to set a key" into "this tool is broken".
395
+ *
396
+ * ⭐ And the simple form is offered first, because `acuvo` loads a `.env`
397
+ * beside the project on its own — the explicit invocation is only needed when
398
+ * the file lives somewhere else.
399
+ */
400
+ 'Keep keys in a file? put OPENROUTER_API_KEY=... in a .env beside your project',
401
+ ' (acuvo loads it automatically — no extra flags)',
402
+ 'Want to check the setup? acuvo --doctor (no key needed, works offline)',
403
+ ].join('\n');
404
+
405
+ /**
406
+ * ── ⚠️⚠️ THE RESPONSE BODY IS NOT TRUSTED TEXT — IT CAN CONTAIN THE KEY ──────
407
+ *
408
+ * Corporate proxies and API gateways routinely echo the offending REQUEST back
409
+ * inside their error page, headers and all. We then printed that body verbatim
410
+ * as `detail`, so the key went to terminal scrollback, to CI job logs, and into
411
+ * whatever the user pastes into a bug report — three places a secret is very
412
+ * hard to recall from. Reproduced 2026-08-10: HTTP 407 with a body of
413
+ * `authorization: Bearer sk-or-v1-…` printed the key in full.
414
+ *
415
+ * ⚠️ THIS RUNS BEFORE THE 400-CHAR SLICE, DELIBERATELY. Truncating first and
416
+ * redacting second is worse than not redacting at all: the cut removes the tail
417
+ * that made the pattern matchable, so a key straddling char 400 survives as a
418
+ * twenty-character prefix that no regex will ever catch again. Measured on the
419
+ * unfixed code — `sk-or-v1-STRADDLECAN` made it to the screen.
420
+ *
421
+ * Whole HEADER LINES go, not just the token: a value we failed to pattern-match
422
+ * is still a credential if it sat after `authorization:`.
423
+ */
424
+ function redact(text) {
425
+ return String(text ?? '')
426
+ // The credential-bearing header, value and all, whatever shape the value is.
427
+ .replace(/^[ \t]*(authorization|proxy-authorization|x-api-key|api-key)[ \t]*:.*$/gim, '<header redacted>')
428
+ // OpenRouter's own key format — hyphens included, so it must run before the
429
+ // generic rule below, which would otherwise stop at the first hyphen.
430
+ .replace(/sk-or-v1-[A-Za-z0-9._-]+/g, 'sk-…redacted')
431
+ // Every other provider's `sk-…` key, loose on purpose: a false positive
432
+ // costs a reader nothing, a false negative costs them a key.
433
+ .replace(/sk-[A-Za-z0-9]{16,}/g, 'sk-…redacted');
434
+ }
435
+
436
+ /**
437
+ * Turn an HTTP status + response body into something a human can act on.
438
+ *
439
+ * Pure. Every branch here is a real OpenRouter behaviour rather than a guess:
440
+ * 402 is what an exhausted balance returns, and it is the single most likely
441
+ * failure for this account — measured 2026-08-09, the key authenticates and the
442
+ * credits endpoint reports `total_credits: 0` against `total_usage: 0.028`.
443
+ *
444
+ * ⚠️ THE THIRD ARGUMENT IS OPTIONAL AND EVERY EXISTING CALLER STAYS CORRECT.
445
+ * `pin` is the provider preference that was SENT with the failed request, and it
446
+ * exists because of a measured misdiagnosis: `ACUVO_PROVIDER_ORDER=DeepSeek`
447
+ * returns HTTP 404, and the 404 branch below told the reader to check
448
+ * `OPENROUTER_CODEGEN_MODEL` against the model catalogue. The model was fine.
449
+ * Every word of the advice pointed away from the one variable that caused it —
450
+ * and because the message matches `isModelSpecific`, `chain.mjs` then spent all
451
+ * four attempts re-sending the SAME bad pin against four different model ids.
452
+ */
453
+ export function classifyHttpFailure(status, bodyText, { pin = null } = {}) {
454
+ /**
455
+ * Rendered once, used only by the branches where a pin can plausibly be the
456
+ * cause. An empty or absent pin adds nothing, so an unpinned run's messages
457
+ * are byte-identical to what they were before this argument existed.
458
+ */
459
+ const pinClause = Array.isArray(pin) && pin.length > 0
460
+ ? `\n\n⚠️ ACUVO_PROVIDER_ORDER=${pin.join(',')} was sent with this request. A provider that does not `
461
+ + 'serve this model — or one excluded by your OpenRouter data policy — makes the request a 404 even '
462
+ + 'though the model id is fine. Unset it to rule the pin out before you change the model.'
463
+ : '';
464
+ const snippet = redact(bodyText || '').slice(0, 400).trim();
465
+ let apiMessage = '';
466
+ try {
467
+ // ⚠️ Parsed from the ORIGINAL body (redaction would not break JSON here, but
468
+ // relying on that is a trap), then redacted on the way out — the provider's
469
+ // own message is just as capable of quoting the key back at us.
470
+ apiMessage = redact(JSON.parse(bodyText)?.error?.message || '');
471
+ } catch {
472
+ /* a non-JSON body is itself information; the snippet carries it */
473
+ }
474
+ const detail = apiMessage || snippet || '(no response body)';
475
+
476
+ if (status === 401 || status === 403) {
477
+ return `OpenRouter rejected the API key (HTTP ${status}). Check OPENROUTER_API_KEY is current and not revoked.\n ${detail}`;
478
+ }
479
+ if (status === 402) {
480
+ return `OpenRouter says this account cannot pay for the call (HTTP 402) — the balance is exhausted.\n ${detail}\n\nTop up at https://openrouter.ai/credits, or set OPENROUTER_CODEGEN_MODEL to a ":free" model id.`;
481
+ }
482
+ if (status === 404) {
483
+ return `OpenRouter does not serve that model (HTTP 404). Check OPENROUTER_CODEGEN_MODEL against https://openrouter.ai/models.\n ${detail}${pinClause}`;
484
+ }
485
+ if (status === 429) {
486
+ return `Rate limited by OpenRouter (HTTP 429). Wait and re-run, or switch OPENROUTER_CODEGEN_MODEL.\n ${detail}`;
487
+ }
488
+ if (status >= 500) {
489
+ return `OpenRouter or the upstream provider failed (HTTP ${status}). This is usually transient — re-run.\n ${detail}`;
490
+ }
491
+ return `The model call failed (HTTP ${status}).\n ${detail}`;
492
+ }
493
+
494
+ /**
495
+ * ── ⚠️ `err.message` IS ALWAYS THE LITERAL STRING 'fetch failed' ─────────────
496
+ *
497
+ * Node's fetch wraps every transport fault in one TypeError with that exact
498
+ * message and hangs the real cause off `err.cause.code`. Reading only `.message`
499
+ * therefore printed IDENTICAL text for a DNS failure, a refused connection, a
500
+ * corporate TLS MITM and a captive portal — four different problems with four
501
+ * different fixes, all reported as "Could not reach OpenRouter: fetch failed".
502
+ * `lib/github.mjs:107` already reads the cause code; this is that shape, with
503
+ * the fix attached.
504
+ */
505
+ const TRANSPORT_CAUSES = {
506
+ ENOTFOUND: 'the hostname did not resolve — check DNS or your network',
507
+ EAI_AGAIN: 'DNS lookup timed out — the resolver is unreachable or overloaded',
508
+ ECONNREFUSED: 'the connection was refused — a proxy or firewall closed it',
509
+ ECONNRESET: 'the connection was reset mid-request',
510
+ ETIMEDOUT: 'the connection timed out before the server answered',
511
+ UND_ERR_SOCKET: 'the socket closed before the response finished',
512
+ DEPTH_ZERO_SELF_SIGNED_CERT: 'a TLS certificate could not be verified — if you are behind a corporate proxy, set NODE_EXTRA_CA_CERTS=/path/to/ca.pem',
513
+ SELF_SIGNED_CERT_IN_CHAIN: 'a TLS certificate could not be verified — if you are behind a corporate proxy, set NODE_EXTRA_CA_CERTS=/path/to/ca.pem',
514
+ UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'a TLS certificate could not be verified — if you are behind a corporate proxy, set NODE_EXTRA_CA_CERTS=/path/to/ca.pem',
515
+ };
516
+
517
+ /**
518
+ * Everything that can go wrong before a reply exists, as one sentence.
519
+ * Separated from the fetch so the table above is testable, and so a transport
520
+ * exception is never re-thrown as a raw stack.
521
+ *
522
+ * ⚠️⚠️ THE PHRASE 'Could not reach OpenRouter' IS AN API, NOT PROSE.
523
+ * `lib/chain.mjs:81` decides retryability by matching error TEXT, and connection
524
+ * failures only fall back to a second provider because they happen to match
525
+ * `/could not reach/i`. Reword this prefix and you silently switch fallback off
526
+ * for the entire class of faults fallback exists for. Change the sentence after
527
+ * it as much as you like; leave those four words alone.
528
+ */
529
+ /**
530
+ * ── ⭐⭐ THE KIND, SO RETRYABILITY STOPS DEPENDING ON A SENTENCE ─────────────
531
+ *
532
+ * The header above says the phrase 'Could not reach OpenRouter' is an API
533
+ * because `chain.mjs` matches error TEXT. That warning was right and it was
534
+ * also incomplete: the TIMEOUT branch never matched anything `isRetryable`
535
+ * looked for. Measured 2026-08-12:
536
+ *
537
+ * isRetryable(describeTransportError({name:'TimeoutError'}, 180000)) === false
538
+ *
539
+ * So the four-model chain never fired on a timeout — the commonest failure of a
540
+ * LONG job, with three healthy fallbacks sitting right there. Long tasks failed
541
+ * more, by design, which is exactly backwards. And the suite stayed green
542
+ * because its test asserted `isRetryable('timed out')`, a literal this function
543
+ * has never produced.
544
+ *
545
+ * ⭐ A WIDER REGEX WOULD ONLY MOVE THE NEXT DRIFT. The classifier should not be
546
+ * reading English at all. This returns the fact; `isRetryable` switches on it,
547
+ * and the sentence becomes free to reword.
548
+ *
549
+ * @param {any} err
550
+ * @returns {'timeout' | 'network' | null}
551
+ */
552
+ export function transportErrorKind(err) {
553
+ const name = err?.name || '';
554
+ if (name === 'TimeoutError' || name === 'AbortError') return 'timeout';
555
+ const code = err?.cause?.code || err?.code || '';
556
+ if (code && TRANSPORT_CAUSES[code]) return 'network';
557
+ // ⚠️ An uncatalogued code is still a transport fault — that is what a `cause`
558
+ // code MEANS. Treating only known codes as network is how ECONNRESET's
559
+ // less-famous siblings quietly stopped failing over.
560
+ if (code) return 'network';
561
+ return null;
562
+ }
563
+
564
+ export function describeTransportError(err, timeoutMs) {
565
+ const name = err?.name || '';
566
+ const message = err instanceof Error ? err.message : String(err);
567
+ if (name === 'TimeoutError' || name === 'AbortError') {
568
+ return `No response from OpenRouter within ${Math.round(timeoutMs / 1000)}s — the call was aborted rather than left hanging.`;
569
+ }
570
+ const code = err?.cause?.code || err?.code || '';
571
+ const known = TRANSPORT_CAUSES[code];
572
+ if (known) return `Could not reach OpenRouter: ${known} (${code}).`;
573
+ // Unknown cause: say the code anyway if there is one. A code we have not
574
+ // catalogued is still searchable; 'fetch failed' on its own is not.
575
+ if (code) return `Could not reach OpenRouter: ${message} (${code}). Check the network, DNS, and any proxy.`;
576
+ return `Could not reach OpenRouter: ${message}\nCheck the network, DNS, and any proxy between you and openrouter.ai.`;
577
+ }
578
+
579
+ /**
580
+ * The assistant message out of an OpenAI-shaped body, or a reason it is absent.
581
+ *
582
+ * @typedef {{ function?: { name?: string, arguments?: string } }} RawToolCall
583
+ * @typedef {{ ok: true, content: string | null, toolCalls: RawToolCall[], finishReason: string | null, usage: { cost?: number, total_tokens?: number } | null }} ReplyOk
584
+ * @param {any} body
585
+ * @returns {ReplyOk | { ok: false, error: string }}
586
+ */
587
+ export function extractReply(body) {
588
+ const choice = body?.choices?.[0];
589
+ if (!choice) return { ok: false, error: 'the model returned no choices — nothing to act on' };
590
+ const message = choice.message;
591
+ if (!message) return { ok: false, error: 'the model returned a choice with no message' };
592
+ const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
593
+
594
+ /**
595
+ * ── ⚠️⚠️ THE DEGENERATE 200: BILLED, AND WITH NOTHING IN IT ────────────────
596
+ *
597
+ * A provider can answer 200 with a message carrying neither content nor a
598
+ * tool call. The call happened, the tokens were billed, and there is nothing
599
+ * to act on. This used to return `ok: true` with `content: null`, and that
600
+ * travelled all the way out as a FINISHED SESSION — exit 0, zero files
601
+ * changed, no error printed, and no fallback ever attempted.
602
+ *
603
+ * ⭐ AND IT MADE chain.mjs's OWN GUARD DEAD CODE. That file carries
604
+ * `if (/empty reply|no content|returned nothing/i.test(e)) return true;` and
605
+ * calls it "the single most important line here" — but it classifies an ERROR
606
+ * STRING, and this function never produced one for this case. So the chain
607
+ * had three healthy fallback models it could never reach on the one failure
608
+ * that costs a whole call. The wording below is chosen to match that pattern;
609
+ * `test/transport-empty-reply.test.mjs` asserts the two agree, so renaming
610
+ * this message without updating the classifier fails a test rather than
611
+ * silently re-opening the hole.
612
+ *
613
+ * ⚠️ `content: null` WITH TOOL CALLS IS THE NORMAL SHAPE — it is what every
614
+ * tool-calling turn looks like, and some providers send `''` rather than
615
+ * null. Only the case with NEITHER is degenerate. Widening this check to
616
+ * "content is empty" would refuse every tool call in the product.
617
+ */
618
+ const hasText = typeof message.content === 'string' && message.content.trim() !== '';
619
+ if (!hasText && toolCalls.length === 0) {
620
+ return {
621
+ ok: false,
622
+ error: 'the model returned an empty reply — no content and no tool calls, so there is nothing to act on',
623
+ };
624
+ }
625
+
626
+ return {
627
+ ok: true,
628
+ content: typeof message.content === 'string' ? message.content : null,
629
+ toolCalls,
630
+ finishReason: choice.finish_reason ?? null,
631
+ // ⚠️ OpenRouter reports the REAL cost of the call in `usage.cost`. Printing
632
+ // it is not decoration: this repo's standing rule is that pre-revenue every
633
+ // infra dollar is burn, and a local tool that spends silently is exactly how
634
+ // a binge happens without anyone noticing.
635
+ usage: body?.usage ?? null,
636
+ /**
637
+ * ⭐ THE UPSTREAM THAT ACTUALLY SERVED IT. OpenRouter puts the serving
638
+ * provider's name on the response body next to `model`, and this function
639
+ * discarded every top-level field it did not name — so the one fact that
640
+ * distinguishes "our prefix regressed" from "we were routed to a cold
641
+ * instance" arrived on every call and was thrown away. Absent stays null:
642
+ * a provider that does not report it is unknown, not "unpinned".
643
+ */
644
+ provider: typeof body?.provider === 'string' && body.provider ? body.provider : null,
645
+ };
646
+ }
647
+
648
+ /**
649
+ * ── ⭐⭐ DID THE PIN TAKE? ────────────────────────────────────────────────────
650
+ *
651
+ * ⚠️ THE DEFECT THIS ANSWERS, MEASURED 2026-08-14: `ACUVO_PROVIDER_ORDER=DeepSeek`
652
+ * returns HTTP 404 "No endpoints found" when it is sent alone — and with
653
+ * `allow_fallbacks: true` (which stays true, see the payload) OpenRouter does not
654
+ * error on an `order` list it cannot honour. It treats it as an empty preference
655
+ * and routes at random. So a pin has THREE outcomes, not two: honoured, rejected
656
+ * loudly, and **accepted, ignored, billed** — and the third was indistinguishable
657
+ * from the first at every layer of this CLI. The measured cost of not knowing:
658
+ * 46.7% hit rate instead of 95.8%, i.e. roughly 2.4× the bill, with no symptom.
659
+ *
660
+ * ⚠️ THE COMPARISON IS CASE-INSENSITIVE ON PURPOSE. The catalogue writes
661
+ * `DeepInfra`; people type `deepinfra`. A pin that "did not take" because of a
662
+ * capital letter would be a false alarm, and one false alarm is all it takes for
663
+ * the real one to be ignored.
664
+ *
665
+ * ── ⚠️⚠️ A LIST IS NOT ONE CACHE, AND "took" USED TO PRETEND IT WAS ─────────
666
+ *
667
+ * This function used to answer `took` for ANY name in the list, on the reasoning
668
+ * that a preference list is honoured if any of its names served the round. That
669
+ * is the right test for AVAILABILITY and the wrong one for the thing the pin
670
+ * exists to buy. **A prompt cache lives on ONE upstream instance.** Landing on
671
+ * the second name in the list is a live provider and a stone-cold cache, and it
672
+ * was scored identically to landing on the first.
673
+ *
674
+ * ⭐ MEASURED 2026-08-16, replaying ONE byte-identical 46,171-byte payload
675
+ * against `order: [StreamLake, Baidu, GMICloud]`:
676
+ *
677
+ * served by StreamLake (first choice) 11,520 of 11,714 cached 98.3% $0.000172
678
+ * served by Baidu (second choice) 0 of 11,714 cached 0.0% $0.000791
679
+ *
680
+ * **4.6× on one round, for the same bytes**, and every layer of this CLI called
681
+ * it `pinTook: 1, pinMissed: 0` — a healthy reading. Over 40 pinned calls the
682
+ * scatter measured StreamLake 38, Baidu 2, so this is a ~5% event that nothing
683
+ * could see and nothing could name.
684
+ *
685
+ * ⚠️ THE FALLBACK IS STILL NOT THE BUG. `allow_fallbacks` stays true — "never
686
+ * single", and a cheaper request that does not happen is not cheaper. What
687
+ * changes here is only that a fallback stops being invisible, which is the same
688
+ * argument that put `missed` here in the first place.
689
+ *
690
+ * @param {{ pin?: string[] | null, served?: string | null }} x
691
+ * @returns {'none' | 'unknown' | 'took' | 'fell-back' | 'missed'}
692
+ * `none` — nothing was pinned. `unknown` — pinned, but the provider never said
693
+ * who served it, so we refuse to guess either way. `took` — the FIRST name
694
+ * served it, which is the only outcome that reuses the cache we have been
695
+ * accumulating. `fell-back` — a later name in the list served it: available,
696
+ * billed, and cold. `missed` — nobody in the list served it.
697
+ */
698
+ export function pinOutcome({ pin = null, served = null } = {}) {
699
+ if (!Array.isArray(pin) || pin.length === 0) return 'none';
700
+ if (typeof served !== 'string' || !served) return 'unknown';
701
+ const want = pin.map((p) => String(p).trim().toLowerCase());
702
+ const got = served.trim().toLowerCase();
703
+ if (want[0] === got) return 'took';
704
+ return want.includes(got) ? 'fell-back' : 'missed';
705
+ }
706
+
707
+ /**
708
+ * ── ⚠️ THE DEFAULT PIN IS THE OWNER'S DECISION, NOT THIS FILE'S ─────────────
709
+ *
710
+ * Empty = today's behaviour exactly: no `provider` field is sent and routing is
711
+ * whatever OpenRouter chooses. Naming a provider here would make a specific third
712
+ * party our default route for every request this package makes — a commercial
713
+ * decision, not an engineering one, so it is left switched off with the switch in
714
+ * plain sight.
715
+ *
716
+ * ⭐ TO TURN IT ON: set this string to a provider name (measured best on
717
+ * 2026-08-14: `'DeepInfra'` — 73.7% and 95.8% hit rates against 46.7% and 48.6%
718
+ * unpinned on the identical 4-round task). `ACUVO_PROVIDER_ORDER` still overrides
719
+ * it per-run, and `pinOutcome` above now makes a pin that does not take visible
720
+ * rather than silent, which is what makes flipping this safe to try.
721
+ */
722
+ /**
723
+ * ── ⭐⭐ THE PIN IS ON, AND IT IS THE WHOLE CACHING STORY ────────────────────
724
+ *
725
+ * 28 upstream endpoints serve this model. A prompt cache lives on ONE instance,
726
+ * so unpinned we are re-routed across all of them and the cache is cold more
727
+ * often than not. MEASURED 2026-08-14, same task, same day:
728
+ *
729
+ * unpinned 48.6% and 46.7% cache hit $0.002217/task
730
+ * pinned 73.7% and 95.8% cache hit $0.000910/task 2.4x cheaper
731
+ *
732
+ * And an isolated probe of three identical calls: unpinned went Relace →
733
+ * GMICloud → GMICloud and only hit 97.1% on the third BY LUCK; pinned, every
734
+ * call after the first hit 97.1%.
735
+ *
736
+ * ── ⭐ WHY THIS PROVIDER, RANKED BY THE ONLY NUMBER THAT MATTERS ────────────
737
+ *
738
+ * Not the headline per-token price — the EFFECTIVE cost at our real cache rate
739
+ * (95.8%) on our real blend (90% input). Measured live from the endpoint feed:
740
+ *
741
+ * StreamLake $0.0327/M DeepInfra $0.0348/M
742
+ * Baidu $0.0327/M DeepSeek $0.0357/M
743
+ * Decart $0.0332/M GMICloud $0.0373/M
744
+ *
745
+ * StreamLake is cheapest, and is what the bench already lands on — it hit 98%
746
+ * cache on a real task this morning.
747
+ *
748
+ * ⚠️⚠️ AND THE HAZARD THE PIN CLOSES IS WORSE THAN THE PRICE SPREAD: **1 of the
749
+ * 28 endpoints publishes no cache-read price at all.** Unpinned, a run can land
750
+ * on the one provider that never caches anything, and nothing would say so —
751
+ * the bill would simply be five times larger with an identical transcript.
752
+ *
753
+ * ⚠️ THIS IS A PREFERENCE, NOT A LOCK. `allow_fallbacks` stays true, so an
754
+ * outage at StreamLake degrades to another endpoint rather than killing every
755
+ * run at once. "Never single" is this package's standing rule and pinning hard
756
+ * would trade an outage for a discount — a cheaper request that does not happen
757
+ * is not cheaper.
758
+ *
759
+ * ⚠️ AND A PIN THAT DOES NOT TAKE IS REPORTED. `pinOutcome` compares what was
760
+ * asked for against what actually served the round, because the failure mode of
761
+ * a silent pin is a worse bill and no symptom — which is exactly how a bad pin
762
+ * fooled a measurement here once already.
763
+ *
764
+ * ⭐ Override or disable with `ACUVO_PROVIDER_ORDER` (empty string = unpinned).
765
+ */
766
+ export const DEFAULT_PROVIDER_ORDER = 'StreamLake';
767
+
768
+ /**
769
+ * ── ⚠️⚠️ A SINGLE GLOBAL PIN IS ONLY EVER CORRECT FOR ONE MODEL ─────────────
770
+ *
771
+ * `DEFAULT_PROVIDER_ORDER = 'StreamLake'` was chosen by measuring FLASH, and
772
+ * **StreamLake does not serve pro at all** — it is not among pro's 7 endpoints.
773
+ * So every pro run asked for a provider that could not answer, the pin matched
774
+ * nothing, `allow_fallbacks` did its job, and OpenRouter routed freely.
775
+ *
776
+ * MEASURED on the 13-task bench, 2026-08-15: **pro was served by GMICloud on
777
+ * 13 of 13 runs.** Compare the two pro endpoints:
778
+ *
779
+ * DeepSeek (the model's author) in $0.435 out $0.870 cache-read $0.0036
780
+ * GMICloud (what we actually got) in $1.218 out $2.436 cache-read $0.1015
781
+ *
782
+ * ⚠️ 2.8x on tokens and **28x on cache reads**. The "pro costs 11.2x flash"
783
+ * figure this package now quotes was measured on the most expensive pro
784
+ * endpoint available, because nobody had pinned the cheap one. Pinned to
785
+ * DeepSeek's own endpoint, pro's cached reads ($0.0036) are ~3.8x CHEAPER than
786
+ * flash's ($0.0137).
787
+ *
788
+ * ⭐ SO THE PIN IS PER-MODEL. A provider list is a fact about a MODEL, not
789
+ * about this package, and pretending otherwise silently unpins every model
790
+ * except the one that was measured.
791
+ *
792
+ * ⚠️ EACH ENTRY IS A LIST, NOT ONE NAME. A single name plus `allow_fallbacks`
793
+ * degrades to *anything* when that provider is down — which is how a cheap run
794
+ * becomes an expensive one with no symptom. Two or three cheap endpoints in
795
+ * order degrade to another CHEAP one first. Still a preference, never a lock:
796
+ * "never single" is the standing rule and a cheaper request that does not
797
+ * happen is not cheaper.
798
+ *
799
+ * Prices read from OpenRouter's per-model endpoint feed on 2026-08-15; the
800
+ * ORDER is what matters and it is cheapest-first by in+out.
801
+ */
802
+ /**
803
+ * ── ⚠️⚠️ AND A NAME IN THIS TABLE IS NOT PROOF IT CAN BE REACHED ────────────
804
+ *
805
+ * MEASURED 2026-08-16 against the live account, `allow_fallbacks:false`, one
806
+ * name at a time:
807
+ *
808
+ * flash StreamLake ✔ Baidu ✔ GMICloud ✔ Decart ✔ **DeepSeek ✘ 404**
809
+ * pro GMICloud ✔ **DeepSeek ✘ 404**
810
+ *
811
+ * `DeepSeek` — the model's own author, the cheapest endpoint on both models,
812
+ * `status: 0` and `uptime_last_30m: 100` in the public feed — answers **"No
813
+ * endpoints found"** for this account on BOTH models, with or without any
814
+ * parameter. That is an OpenRouter **data-policy exclusion**, not a typo and not
815
+ * an outage, and it is fixed in the account settings, not here.
816
+ *
817
+ * ⚠️ SO PRO'S PIN HAS ALWAYS RESOLVED TO GMICloud ALONE, and every layer called
818
+ * it `pinTook`. That is exactly the silence `pinFellBack` was added to end: pro
819
+ * runs now say "DeepSeek did not serve N rounds" instead of nothing.
820
+ *
821
+ * ⭐ THE NAME STAYS ANYWAY, first. It is the right endpoint the moment the
822
+ * policy allows it — **eff. $0.098/M against GMICloud's $0.355/M at a 98% cache
823
+ * rate on a 90/10 blend, 3.6× cheaper** — and deleting it would quietly convert
824
+ * a fixable account setting into a permanent 3.6× overpayment nobody remembers.
825
+ *
826
+ * ⚠️ WHAT WAS ACTUALLY WRONG WITH PRO'S LIST IS THAT IT WAS ONE REACHABLE NAME.
827
+ * The rule two paragraphs up — "a single name plus `allow_fallbacks` degrades to
828
+ * *anything*" — was being broken by the pin that had a second entry on paper.
829
+ * With GMICloud down, pro degraded to whatever answered: SiliconFlow is
830
+ * $0.808/M, 2.3× GMICloud. Fireworks and Cloudflare are the next-cheapest
831
+ * REACHABLE endpoints ($0.459/M) and are named so the degradation is cheap-first.
832
+ *
833
+ * Prices re-read from the live endpoint feed 2026-08-16.
834
+ */
835
+ export const PROVIDER_PIN_BY_MODEL = Object.freeze({
836
+ // 28 endpoints. The three cheapest are within 3% of each other, and all three
837
+ // are reachable (checked one at a time, 2026-08-16).
838
+ 'deepseek/deepseek-v4-flash-0731': Object.freeze(['StreamLake', 'Baidu', 'GMICloud']),
839
+ // 8 endpoints, and the spread is enormous — this is the one that was costing us.
840
+ // ⚠️ `DeepSeek` is 404 for this account (see above); GMICloud is the cheapest
841
+ // endpoint we can actually reach, and the two after it keep the fall cheap.
842
+ 'deepseek/deepseek-v4-pro-0813': Object.freeze(['DeepSeek', 'GMICloud', 'Fireworks', 'Cloudflare']),
843
+ // Exactly one endpoint; pinning it changes nothing today and states the fact.
844
+ 'qwen/qwen3.7-flash': Object.freeze(['Alibaba']),
845
+ 'z-ai/glm-4.6': Object.freeze(['Venice', 'DeepInfra']),
846
+ });
847
+
848
+ /**
849
+ * The provider order to ask for, given the model about to be called.
850
+ *
851
+ * @param {string} model
852
+ * @param {Record<string,string|undefined>} [env]
853
+ * @returns {{ order: string[], source: 'env' | 'model' | 'default' | 'none' }}
854
+ */
855
+ export function providerOrderFor(model, env = process.env) {
856
+ const raw = env?.ACUVO_PROVIDER_ORDER;
857
+ /**
858
+ * ⚠️ UNSET vs EXPLICITLY EMPTY, and the difference is the off switch. An
859
+ * explicit '' means "do not pin at all" and must not fall through to a
860
+ * default — a `??` here was a real bug once, where the documented way to
861
+ * unpin quietly did nothing.
862
+ */
863
+ if (raw !== undefined && raw !== null) {
864
+ const order = String(raw).split(',').map((s) => s.trim()).filter(Boolean);
865
+ return { order, source: order.length ? 'env' : 'none' };
866
+ }
867
+ const byModel = PROVIDER_PIN_BY_MODEL[String(model ?? '')];
868
+ if (byModel && byModel.length) return { order: [...byModel], source: 'model' };
869
+ /**
870
+ * ⚠️ AN UNKNOWN MODEL IS LEFT UNPINNED RATHER THAN GIVEN FLASH'S PIN. Asking
871
+ * for a provider that does not serve the model is exactly the bug above: it
872
+ * looks pinned, matches nothing, and routes freely to whatever is dearest.
873
+ * No pin at least tells the truth, and `pinOutcome` reports it.
874
+ */
875
+ return { order: [], source: 'none' };
876
+ }
877
+
878
+ /**
879
+ * One completion. Never throws — returns `{ ok }` either way, because the
880
+ * caller's job is to print a summary, not to catch.
881
+ *
882
+ * @returns {Promise<(ReplyOk & { model: string }) | { ok: false, error: string }>}
883
+ */
884
+ /**
885
+ * ── ⭐ STREAMING IS OPT-IN PER CALL, NOT A MODE ─────────────────────────────
886
+ * `onText` present = stream. Absent = the exact previous behaviour, byte for
887
+ * byte. That keeps every existing test, the bench, and any caller that wants a
888
+ * whole answer unchanged — a global switch would have made "did streaming break
889
+ * this?" a question on every future bug.
890
+ */
891
+ export async function callModel({
892
+ apiKey, model, messages, onText = null,
893
+ // ⚠️ THE CALLER CHOOSES WHAT TO OFFER. Defaulting to the whole registry keeps
894
+ // this honest for a future multi-round client; the single-shot turn narrows
895
+ // it deliberately (see SINGLE_SHOT_TOOL_NAMES and the measurement behind it).
896
+ tools = TOOL_SCHEMAS,
897
+ timeoutMs = DEFAULT_TIMEOUT_MS, maxTokens = DEFAULT_MAX_TOKENS, fetchImpl = fetch,
898
+ /**
899
+ * ⚠️ Off only for a test that asserts the single-attempt payload. Production
900
+ * never sets it — the warm-first attempt IS the cache floor, and a flag that
901
+ * quietly disables it would be the defect this change exists to remove.
902
+ */
903
+ retryOnPinFailure = true,
904
+ // ⚠️ Injected so the provider preference below is testable without touching
905
+ // the real environment — and so a library caller can set it explicitly.
906
+ env = process.env,
907
+ /**
908
+ * ── ⭐ AN OBSERVED ROUTE, NOT A CONFIGURED ONE ────────────────────────────
909
+ *
910
+ * `warm-provider.mjs` watches who ACTUALLY served earlier rounds and asks for
911
+ * that one name with fallbacks off, because a prompt cache lives on a single
912
+ * upstream and `provider.order` is only a preference. Measured live: a round
913
+ * that landed on the pin's second name was 0% cached and 4.6× the price for
914
+ * byte-identical input.
915
+ *
916
+ * ⚠️ STRICT IS SAFE HERE ONLY BECAUSE THE NAME WAS SEEN TO SERVE. Strictness
917
+ * on a CONFIGURED name is a single point of failure — that is why
918
+ * `ACUVO_PROVIDER_STRICT` is opt-in, and this deliberately does not reuse it.
919
+ * `null` leaves every existing caller byte-identical.
920
+ */
921
+ routeOverride = null,
922
+ /**
923
+ * ── ⭐⭐⭐ THE STICKY KEY — AND THE FEATURE OUR OWN FIX WAS SWITCHING OFF ───
924
+ *
925
+ * Everything this file says about the routing lottery is correct: a prompt
926
+ * cache lives on ONE upstream, `provider.order` is only a preference over
927
+ * PROVIDERS, and StreamLake is a fleet — so pinning the provider never pinned
928
+ * the machine, and successive cold processes measured 65 / 98 / 31 / 98.
929
+ *
930
+ * ⚠️⚠️ WHAT NONE OF THAT NOTICED IS THAT OPENROUTER SOLVES THIS, AND WE WERE
931
+ * DISABLING IT. Their prompt-caching documentation, verbatim:
932
+ *
933
+ * "Sticky routing is not used when you specify a manual provider order via
934
+ * `provider.order` — in that case, your explicit ordering takes priority."
935
+ *
936
+ * "When `session_id` is set, sticky routing activates on any successful
937
+ * request — even before cache usage is observed — so that subsequent
938
+ * requests in the same session benefit from prompt caching from the start."
939
+ *
940
+ * ⭐ So the warm-first pin — the change written specifically to win the cache
941
+ * back — is the one thing that turns off the mechanism that pins the actual
942
+ * SERVER. We diagnosed "pinning the provider does not pin the machine" and
943
+ * then concluded the machine could not be pinned; in fact it can, and our pin
944
+ * was what stopped it. That is why round 1 was always 0% and why the same
945
+ * task warmed 0 → 79 → 99 instead of starting warm.
946
+ *
947
+ * ⚠️ UNPROVEN UNTIL MEASURED, AND SAID PLAINLY. This is read from their docs,
948
+ * not from our own numbers — the honest test is four cold runs sharing a
949
+ * session id, which needs credits. It is defensible before that measurement
950
+ * only because it cannot be worse: `session_id` is inert if stickiness never
951
+ * engages, and `only` restricts exactly what `order` restricted for a
952
+ * one-element list. `null` leaves every existing caller byte-identical.
953
+ */
954
+ sessionId = null,
955
+ }) {
956
+ const streaming = typeof onText === 'function';
957
+ /**
958
+ * ── ⭐⭐ CACHE STICKINESS: THE PREFIX IS PERFECT AND THE ROUTING IS NOT ─────
959
+ *
960
+ * Measured 2026-08-12 with a scripted model, so the numbers are about the
961
+ * bytes WE send: **97.0% and 97.9% of rounds 2 and 3 were a byte-identical
962
+ * re-send** of the previous round, and the `tools` array was identical every
963
+ * round. Our side of the cache contract is essentially optimal.
964
+ *
965
+ * ⚠️ AND REAL RUNS THE SAME DAY REPORTED 0%, 32%, 33% HIT RATES. The gap is
966
+ * not ours: a prompt cache lives on ONE upstream instance, and OpenRouter is
967
+ * free to route each round to a different provider behind the same model id —
968
+ * this file already documents that varying ("Baidu vs StreamLake on the same
969
+ * model id"). Round 2 landing elsewhere is a cold cache no prefix discipline
970
+ * can fix.
971
+ *
972
+ * ⭐ `ACUVO_PROVIDER_ORDER` (comma-separated) pins the preference so successive
973
+ * rounds tend to reach the same instance.
974
+ *
975
+ * ⚠️ `allow_fallbacks` STAYS TRUE, and that is not a detail. "Never single"
976
+ * is this package's standing rule: pinning hard would trade an outage for a
977
+ * discount, and a cheaper request that does not happen is not cheaper. This
978
+ * expresses a PREFERENCE and keeps the chain underneath it.
979
+ *
980
+ * ⚠️ OFF UNLESS SET. Provider names are an OpenRouter catalogue detail that
981
+ * changes without notice, and inventing one would route every request at a
982
+ * provider that may not serve this model — so the default sends no `provider`
983
+ * field at all and behaves exactly as before. `DEFAULT_PROVIDER_ORDER` is the
984
+ * one-line switch that changes that, and it is deliberately empty.
985
+ *
986
+ * ⚠️⚠️ AND A PIN THAT DOES NOT TAKE IS SILENT — measured, 2026-08-14. See
987
+ * `pinOutcome` above for the three outcomes and what the silence costs. The
988
+ * fallback is NOT the bug and is not being removed; the silence is, and the
989
+ * pin now travels back on the reply so the round record can name it.
990
+ */
991
+ /**
992
+ * ⚠️⚠️ WHETHER THE PIN WAS CHOSEN BY A HUMAN OR INHERITED FROM OUR DEFAULT,
993
+ * AND THE DIFFERENCE IS LOAD-BEARING FOR `ACUVO_PROVIDER_STRICT`.
994
+ *
995
+ * Strict turns a pin into `allow_fallbacks:false` — an outage becomes a 404
996
+ * instead of a re-route. That is correct for a benchmark and catastrophic as
997
+ * an inherited default: the day StreamLake has a bad ten minutes, every run
998
+ * dies at once, which is precisely the "never single" failure this package
999
+ * refuses to accept.
1000
+ *
1001
+ * ⭐ So strict applies ONLY to a pin somebody named. Our default is a
1002
+ * PREFERENCE and can never be promoted to a lock by a second flag. A test
1003
+ * caught this the moment the default was switched on — `ACUVO_PROVIDER_STRICT`
1004
+ * alone used to be inert, and without this it would silently have become a
1005
+ * hard lock on a provider the user never chose.
1006
+ */
1007
+ /**
1008
+ * ⚠️ UNSET AND EXPLICITLY-EMPTY ARE DIFFERENT, AND CONFLATING THEM COSTS THE
1009
+ * OFF SWITCH. `ACUVO_PROVIDER_ORDER=''` is how somebody says "no pin, give me
1010
+ * the lottery back" — for a routing experiment, or because a provider is
1011
+ * having a bad day. A first version of this used `??`, so an explicit empty
1012
+ * string fell through to the default and the variable could not turn the
1013
+ * feature off at all.
1014
+ *
1015
+ * ⚠️ AND MY OWN TEST ASSERTED THAT PROPERTY AND MISSED IT, because it
1016
+ * reimplemented this expression locally instead of calling `callModel`. A test
1017
+ * that copies the logic it is checking verifies the copy.
1018
+ */
1019
+ /**
1020
+ * ⚠️ RESOLVED PER MODEL. A single global name was only ever correct for the
1021
+ * model it was measured on: `StreamLake` does not serve pro at all, so every
1022
+ * pro run asked for a provider that could not answer, matched nothing, and
1023
+ * routed freely to GMICloud at 2.8x the tokens and 28x the cache reads.
1024
+ * Measured: pro was served by GMICloud on 13 of 13 bench runs. See
1025
+ * `PROVIDER_PIN_BY_MODEL`.
1026
+ */
1027
+ const explicitRaw = env?.ACUVO_PROVIDER_ORDER;
1028
+ const hasExplicit = explicitRaw !== undefined && explicitRaw !== null;
1029
+ const resolvedPin = providerOrderFor(model, env);
1030
+ const providerOrder = resolvedPin.order;
1031
+ const pinWasChosen = resolvedPin.source === 'env';
1032
+
1033
+ /**
1034
+ * ── ⚠️ THE HARD PIN, FOR PEOPLE WHO GENUINELY WANT ONE ─────────────────────
1035
+ * `allow_fallbacks:false` turns an unhonourable pin into an HTTP 404 instead
1036
+ * of a silent re-route. That is the RIGHT answer for a benchmark or a cache
1037
+ * experiment and the WRONG default for a tool people work in: "never single"
1038
+ * is this package's standing rule, and a cheaper request that does not happen
1039
+ * is not cheaper. Opt-in, off unless `ACUVO_PROVIDER_STRICT` is truthy, and
1040
+ * meaningless without a pin to be strict about.
1041
+ */
1042
+ /**
1043
+ * ⚠️ `pinWasChosen`, NOT `providerOrder.length` — see the note above. Since the
1044
+ * default pin arrived, the length test would let `ACUVO_PROVIDER_STRICT=1`
1045
+ * alone harden a provider the user never named into a single point of failure.
1046
+ * Strict is only ever strict about a pin a human typed.
1047
+ */
1048
+ const strictPin = pinWasChosen
1049
+ && /^(1|true|yes|on)$/i.test(String(env?.ACUVO_PROVIDER_STRICT ?? '').trim());
1050
+
1051
+ /**
1052
+ * ── ⭐⭐ WHAT WE ASKED FOR TRAVELS BACK WITH WHAT WE GOT ────────────────────
1053
+ *
1054
+ * ⚠️ WITHOUT THIS THE COMPARISON IS IMPOSSIBLE ANYWHERE ELSE. `turn.mjs` sees
1055
+ * a reply, not an environment: it can be told which upstream served the round
1056
+ * (`provider`, off the response) but it has no way to know which one was
1057
+ * REQUESTED, and "served by Decart" is only a finding next to "we asked for
1058
+ * DeepInfra". Reading `process.env` again in the loop would be the wrong fix —
1059
+ * `env` is injected here precisely so a library caller can set it per call,
1060
+ * and a second reader would disagree with this one the first time anybody did.
1061
+ *
1062
+ * `null`, never `[]`, when nothing was pinned: an empty array reads as "a pin
1063
+ * that matched nothing", which is the opposite of "no pin".
1064
+ */
1065
+ /**
1066
+ * ⚠️ THE OVERRIDE WINS, AND ONLY IT MAY BE STRICT WITHOUT `ACUVO_PROVIDER_STRICT`.
1067
+ * It carries a provider we watched serve this session, so it is known-reachable
1068
+ * — the property a configured name cannot promise (pro's pin starts with
1069
+ * `DeepSeek`, which 404s for this account).
1070
+ */
1071
+ const overrideOrder = Array.isArray(routeOverride?.order) ? routeOverride.order.filter(Boolean) : [];
1072
+ const useOverride = overrideOrder.length > 0;
1073
+ const effectiveOrder = useOverride ? overrideOrder : providerOrder;
1074
+ const effectiveStrict = useOverride ? Boolean(routeOverride.strict) : strictPin;
1075
+
1076
+ const providerPin = effectiveOrder.length > 0 ? [...effectiveOrder] : null;
1077
+
1078
+ /**
1079
+ * ── ⚠⚠⚠ WARM FIRST, THEN FALL BACK — THE 90% CACHE FLOOR ─────────────
1080
+ *
1081
+ * Roman, 2026-08-19: *"that caching needs to be 90 … you've said it
1082
+ * permanently is, yet it isn't."* He is right, and this is the cause.
1083
+ *
1084
+ * ⚠️ MEASURED ACROSS 90 REAL RUNS from our own audit ledger: token-weighted
1085
+ * hit rate **51.2%**, only 18 of 90 runs at or above 90%, and round 1
1086
+ * non-zero on just 3 of 16. The prompt is NOT the problem — the system
1087
+ * message is byte-identical across processes (3,671 chars, shared prefix
1088
+ * 3,671/3,671). **The routing is.**
1089
+ *
1090
+ * `provider.order` is a PREFERENCE over several upstreams and
1091
+ * `allow_fallbacks: true` lets OpenRouter pick freely. A prompt cache lives on
1092
+ * exactly ONE upstream, so a fresh process lands wherever and starts cold.
1093
+ * With round 1 cold, an N-round run cannot exceed (n−1)/n — a 3-round task is
1094
+ * capped at 67% however perfect the prompt is. That is why "90% always" was
1095
+ * arithmetically impossible, not merely unmet.
1096
+ *
1097
+ * ⭐ THE FIX IS NOT THE TRADE IT LOOKS LIKE. "Never single" rightly refuses a
1098
+ * bare `allow_fallbacks: false`, because one provider having a bad ten minutes
1099
+ * would be an outage for every user at once. So we do BOTH, in order:
1100
+ *
1101
+ * attempt 1 one provider, `allow_fallbacks: false` → lands on the warm cache
1102
+ * attempt 2 the full order, fallbacks on → only if attempt 1 fails
1103
+ *
1104
+ * The lock is what buys the cache; the retry is what keeps "never single"
1105
+ * true. **Neither half is optional** — shipping the lock alone would be a real
1106
+ * availability regression, and shipping the retry alone changes nothing.
1107
+ *
1108
+ * ⚠️ AND IT RETRIES ONLY ON AVAILABILITY FAILURES. A 401, 402 or 404 is not
1109
+ * the provider being busy — it is the key, the balance, or the model id, and
1110
+ * every one of those fails identically on the second attempt. Retrying them
1111
+ * would double the latency of the most common real errors and spend money to
1112
+ * learn nothing.
1113
+ */
1114
+ /**
1115
+ * ── ⭐⭐⭐ THE WARM ATTEMPT USES `only`, NOT `order` — AND THAT IS THE FIX ──
1116
+ *
1117
+ * Both restrict the request to one provider. Only one of them switches off
1118
+ * OpenRouter's sticky routing, which is the feature that pins the SERVER
1119
+ * rather than the company:
1120
+ *
1121
+ * order: ['StreamLake'], allow_fallbacks: false
1122
+ * -> "your explicit ordering takes priority", sticky routing OFF,
1123
+ * every cold process rolls the dice inside the fleet. Measured:
1124
+ * 65 / 98 / 31 / 98.
1125
+ * only: ['StreamLake']
1126
+ * -> the same one-provider restriction, expressed as a WHITELIST. There
1127
+ * is no ordering to take priority over, so nothing documented turns
1128
+ * stickiness off.
1129
+ *
1130
+ * ⚠️ HOW SURE I AM, EXACTLY: that `order` disables sticky routing is quoted
1131
+ * verbatim from their docs and confirmed by a second source. That `only`
1132
+ * PRESERVES it is an inference — their docs do not discuss `only` at all, and
1133
+ * I will not write that they do. It is the right change anyway because it is
1134
+ * weakly dominant: identical restriction, and either stickiness survives (a
1135
+ * large win) or it does not (exactly today's behaviour).
1136
+ *
1137
+ * ⚠️ THE SECOND ATTEMPT KEEPS `order`, deliberately. It exists to survive the
1138
+ * first provider being unavailable, and there ORDERING IS THE POINT — try
1139
+ * these, in this sequence. Losing stickiness on a leg that only runs when the
1140
+ * warm machine already failed costs nothing that was not already lost.
1141
+ */
1142
+ /**
1143
+ * ── ⚠️ AND THE ONE-NAME CASE, WHICH THE FIRST VERSION OF THIS FIX MISSED ──
1144
+ *
1145
+ * `warmFirst` required `effectiveOrder.length > 1`, so a pin of a SINGLE
1146
+ * provider — including `ACUVO_PROVIDER_ORDER='Novita'`, and
1147
+ * `provider-routing-visibility.test.mjs` asserts the default is deliberately
1148
+ * *"one preferred provider, not a list pretending to be a policy"* — fell to
1149
+ * the bottom branch and shipped `order` + `allow_fallbacks: true`.
1150
+ *
1151
+ * ⭐ THAT IS THE WORST OF BOTH. A manual order disables sticky routing, and
1152
+ * `allow_fallbacks: true` means the request can land anywhere anyway. So the
1153
+ * configuration that has ALREADY DECIDED which provider it wants was the one
1154
+ * getting neither the pin nor the stickiness. One name now takes the same
1155
+ * warm-then-fall-back path as several.
1156
+ */
1157
+ const warmFirst = effectiveOrder.length > 0 && !effectiveStrict && retryOnPinFailure !== false;
1158
+ const attempts = warmFirst
1159
+ ? [
1160
+ { only: [effectiveOrder[0]] },
1161
+ { order: effectiveOrder, allowFallbacks: true },
1162
+ ]
1163
+ /**
1164
+ * ⭐ STRICT IS `only` TOO, and it is the truest expression of it: a
1165
+ * whitelist cannot be left, so an unavailable upstream is a 404 rather than
1166
+ * a silent re-route — exactly what strict was always asking for, now said
1167
+ * in the vocabulary that keeps stickiness.
1168
+ *
1169
+ * ⚠️ ONE REAL TRADE, STATED: with several names, `only` drops the
1170
+ * PREFERENCE between them. Strict callers pin one name in practice, and the
1171
+ * alternative is keeping an ordering that switches off the server pinning
1172
+ * this whole change exists for.
1173
+ */
1174
+ : effectiveStrict && effectiveOrder.length > 0
1175
+ ? [{ only: effectiveOrder }]
1176
+ : [{ order: effectiveOrder, allowFallbacks: !effectiveStrict }];
1177
+
1178
+ const payload = {
1179
+ model,
1180
+ messages,
1181
+ tools,
1182
+ ...(effectiveOrder.length > 0
1183
+ ? { provider: { order: effectiveOrder, allow_fallbacks: !effectiveStrict } }
1184
+ : {}),
1185
+ ...(streaming ? { stream: true } : {}),
1186
+ /**
1187
+ * The sticky key. See `sessionId` above for why this is the whole caching
1188
+ * story and not a nicety. Omitted entirely when absent so no existing
1189
+ * caller's wire body changes by one byte.
1190
+ *
1191
+ * WARNING: it is also OpenRouter's grouping key on the Logs page, so the
1192
+ * value must identify a CONVERSATION, never a user or a tenant — a shared
1193
+ * value would pin unrelated traffic to one machine and pool the logs of
1194
+ * people who have nothing to do with each other.
1195
+ */
1196
+ ...(sessionId ? { session_id: String(sessionId).slice(0, 256) } : {}),
1197
+ tool_choice: 'auto',
1198
+ /**
1199
+ * ⚠️ THE FIELD THAT DECIDES WHETHER A MULTI-FILE TASK IS POSSIBLE AT ALL.
1200
+ * In a one-round turn, "one tool call per response" means one FILE per
1201
+ * command — measured 2026-08-09: asked for a module plus its test, the model
1202
+ * wrote the module, said it was writing both, and there was no second round
1203
+ * to finish in. A direct probe of the same model DID return two calls, so
1204
+ * the capability is there and OpenRouter's upstream routing (Baidu vs
1205
+ * StreamLake on the same model id) is what varies.
1206
+ *
1207
+ * ⭐ CORRECTED 2026-08-09 — AND THE ORIGINAL NOTE WAS BLAMING THE WRONG
1208
+ * THING. It read: "this model writes ONE file per response regardless of how
1209
+ * many it promises… parallel_tool_calls did not fix it." That was measured on
1210
+ * `deepseek-v3.2`, and it is NOT true of `deepseek-v4-flash-0731`.
1211
+ *
1212
+ * Re-measured on v4 with reasoning disabled: asked for three files
1213
+ * (src/add.js, src/sub.js, src/index.js re-exporting both) it wrote **all
1214
+ * three in one turn**, correctly, for $0.000110 — and the result runs:
1215
+ * add(2,3)=5, sub(9,4)=5.
1216
+ *
1217
+ * ⚠️ THE LESSON IS ABOUT THE NOTE, NOT THE MODEL. A limitation was recorded
1218
+ * against "this model" when it belonged to one specific version, and it then
1219
+ * read as a permanent ceiling — the kind of stale pessimism that stops people
1220
+ * retrying something that already works. Version the claim or do not make it.
1221
+ */
1222
+ parallel_tool_calls: true,
1223
+ /**
1224
+ * ── ⚠️⚠️ WITHOUT THIS, v4 AND qwen3.7 RETURN NOTHING AT ALL ──────────────
1225
+ * Measured 2026-08-09 across three models in one day. `deepseek-v4-*` and
1226
+ * `qwen3.7-*` ship a native reasoning budget ON BY DEFAULT and will spend
1227
+ * the whole completion allowance thinking, returning
1228
+ * `choices[0].message.content: null` — a billed HTTP 200 with no answer and
1229
+ * no tool calls, which this CLI would report as "the model wrote nothing".
1230
+ *
1231
+ * ⚠️ It is NOT a small-budget problem: the codegen bake-off gave v4 9,000
1232
+ * tokens and still got 0 bytes back. Capping reasoning EFFORT does not help;
1233
+ * only switching it off does.
1234
+ *
1235
+ * On identical input, off measured 1.7x cheaper AND 1.9x faster AND the only
1236
+ * setting that produces output. The console applies the same rule in
1237
+ * `lib/llm.ts` (REASONING_ON_BY_DEFAULT) — ⚠️ two copies of one fact, which
1238
+ * is a real debt: the shared transport this package still lacks is where it
1239
+ * belongs.
1240
+ */
1241
+ ...(/(qwen3\.7|deepseek-v4)/i.test(model) ? { reasoning: { enabled: false } } : {}),
1242
+ max_tokens: maxTokens,
1243
+ temperature: 0.2,
1244
+ // Asking for usage accounting is free and is the only way the cost line
1245
+ // below is a measurement rather than an estimate.
1246
+ usage: { include: true },
1247
+ };
1248
+
1249
+ /**
1250
+ * ⚠️ 401/402/404 ARE NOT AVAILABILITY. A bad key, an empty balance or a wrong
1251
+ * model id fails identically on every provider, so a second attempt costs
1252
+ * latency and teaches nothing. Everything else — transport, 5xx, 429 — is the
1253
+ * pinned upstream being unreachable or busy, which is what the fallback is for.
1254
+ */
1255
+ const worthFallingBackFrom = (status) => status !== 401 && status !== 402 && status !== 404;
1256
+
1257
+ /**
1258
+ * ⭐ DIRECT BEATS THE LOTTERY. When a DeepSeek key is present and the model is
1259
+ * one we can name on their API, the whole provider-order dance is skipped:
1260
+ * one vendor, one endpoint, their own context cache, no aggregator picking a
1261
+ * server. `attempts` is collapsed to a single unpinned call because there is
1262
+ * nothing left to pin — and no fallback, because falling back to OpenRouter
1263
+ * mid-run would land on a cold machine and undo the reason we came here.
1264
+ */
1265
+ /**
1266
+ * ── ⚠️⚠️⚠️ AND THE TRAP THAT MEASURING THE KEY EXPOSED, 2026-08-19 ────────
1267
+ *
1268
+ * The paragraph above ended *"and no fallback, because falling back to
1269
+ * OpenRouter mid-run would land on a cold machine and undo the reason we came
1270
+ * here."* That is correct about the CACHE and catastrophic about AVAILABILITY,
1271
+ * and the live probe is what showed it:
1272
+ *
1273
+ * POST api.deepseek.com/chat/completions
1274
+ * → 402 {"message":"Insufficient Balance"}
1275
+ *
1276
+ * The key in the repo is VALID and has NO MONEY. Combined with
1277
+ * `worthFallingBackFrom` — which excludes 401/402/404 on the stated grounds
1278
+ * that *"a bad key, an empty balance or a wrong model id fails identically on
1279
+ * every provider"* — that made every single call fail hard with no second
1280
+ * attempt, the moment anyone exported the key.
1281
+ *
1282
+ * ⭐ THAT PREMISE IS TRUE FOR ONE ACCOUNT AND FALSE FOR TWO. It was written
1283
+ * when every attempt was a different PROVIDER ORDER on one OpenRouter key, so
1284
+ * an empty balance really was the same fact each time. A direct vendor call
1285
+ * uses a DIFFERENT VENDOR, a DIFFERENT KEY and a DIFFERENT BALANCE, and
1286
+ * DeepSeek being out of credit says precisely nothing about OpenRouter. The
1287
+ * rule did not change; the world it described did.
1288
+ *
1289
+ * ⚠️ VERIFIED NOT LIVE TODAY: `DEEPSEEK_API_KEY` is absent from the Vercel
1290
+ * environment and from the shell, so nothing is broken in production right
1291
+ * now. It is a loaded trap, not a fire — and the fix belongs in before the
1292
+ * top-up that arms it, not after.
1293
+ *
1294
+ * ⭐ So the routes are now heterogeneous: the direct vendor FIRST (for the
1295
+ * cache), then the OpenRouter ladder behind it (for availability). A cold
1296
+ * answer beats no answer. The cache argument only ever applied to a call that
1297
+ * SUCCEEDED, and this fallback fires only when one did not.
1298
+ */
1299
+ const direct = directDeepSeek(model, env);
1300
+ const openRouterRoutes = attempts.map((a) => ({
1301
+ endpoint: resolveApiUrl(), key: apiKey, model,
1302
+ order: a.order ?? null, only: a.only ?? null, allowFallbacks: a.allowFallbacks, direct: false,
1303
+ }));
1304
+ const routes = direct
1305
+ ? [{ endpoint: direct.url, key: direct.apiKey, model: direct.model, order: null, only: null, allowFallbacks: true, direct: true }, ...openRouterRoutes]
1306
+ : openRouterRoutes;
1307
+
1308
+ let res = null;
1309
+ let transportFail = null;
1310
+
1311
+ for (let i = 0; i < routes.length; i += 1) {
1312
+ const attempt = routes[i];
1313
+ const isLast = i === routes.length - 1;
1314
+ const endpoint = attempt.endpoint;
1315
+ const authKey = attempt.key;
1316
+ const wireModel = attempt.model;
1317
+ /**
1318
+ * The three shapes this can take, and they are not interchangeable:
1319
+ * only -> one provider, whitelist, sticky routing left alone (the warm leg)
1320
+ * order -> a sequence to try, sticky routing off by OpenRouter's rule
1321
+ * none -> a direct vendor call, which has no provider concept at all
1322
+ */
1323
+ const body = {
1324
+ ...payload,
1325
+ model: wireModel,
1326
+ ...(attempt.only
1327
+ ? { provider: { only: attempt.only } }
1328
+ : (attempt.order && attempt.order.length > 0
1329
+ ? { provider: { order: attempt.order, allow_fallbacks: attempt.allowFallbacks } }
1330
+ : {})),
1331
+ };
1332
+ /**
1333
+ * ⚠⚠ `provider` IS AN OPENROUTER FIELD AND MUST NOT REACH DEEPSEEK.
1334
+ * The base payload carries it, and the per-attempt spread only OVERRIDES it
1335
+ * — it cannot remove it. So a direct call was shipping
1336
+ * `provider: { order: ['StreamLake', ...] }` to an API that has never heard
1337
+ * of StreamLake. Caught by printing the wire body rather than trusting the
1338
+ * branch, and it is the kind of thing that 400s in production and reads as
1339
+ * "DeepSeek is down".
1340
+ *
1341
+ * ⚠️ `attempt.direct`, NOT `direct`. Once the routes became heterogeneous
1342
+ * this had to become per-route: `direct` is now true for the whole CALL
1343
+ * whenever a DeepSeek key exists, so testing it here would strip the
1344
+ * `provider` pin off every OpenRouter fallback — silently unpinning the
1345
+ * ladder and handing us back the routing lottery we went direct to escape.
1346
+ * The same mistake in the opposite direction as the bug this comment is
1347
+ * about, made by the fix for it.
1348
+ */
1349
+ if (attempt.direct) delete body.provider;
1350
+
1351
+ let attemptRes = null;
1352
+ try {
1353
+ attemptRes = await fetchImpl(endpoint, {
1354
+ method: 'POST',
1355
+ headers: {
1356
+ Authorization: `Bearer ${authKey}`,
1357
+ 'Content-Type': 'application/json',
1358
+ // OpenRouter uses these for attribution; harmless and polite.
1359
+ 'HTTP-Referer': 'https://acuvo.xxiautomate.com',
1360
+ 'X-Title': 'Acuvo Code',
1361
+ },
1362
+ body: JSON.stringify(body),
1363
+ signal: AbortSignal.timeout(timeoutMs),
1364
+ });
1365
+ } catch (err) {
1366
+ transportFail = { ok: false, error: describeTransportError(err, timeoutMs), kind: transportErrorKind(err) };
1367
+ if (isLast) return transportFail;
1368
+ continue;
1369
+ }
1370
+
1371
+ /**
1372
+ * ⭐ A non-ok status falls through to the existing handler below on the LAST
1373
+ * attempt, or on any status a second provider could not fix.
1374
+ *
1375
+ * ⚠️ EXCEPT FROM THE DIRECT ROUTE, WHERE EVERY FAILURE IS WORTH LEAVING.
1376
+ * `worthFallingBackFrom` excludes 401/402/404 because on one account those
1377
+ * fail identically however many times you ask. Leaving the DIRECT vendor is
1378
+ * not asking again — it is asking a DIFFERENT COMPANY with a different key
1379
+ * and a different balance. `Insufficient Balance` at DeepSeek is the single
1380
+ * most likely failure here (it is what the live probe returned) and it is
1381
+ * exactly the one the shared predicate would refuse to escape.
1382
+ */
1383
+ const canLeave = attempt.direct ? true : worthFallingBackFrom(attemptRes.status);
1384
+ if (attemptRes.ok || isLast || !canLeave) { res = attemptRes; break; }
1385
+ }
1386
+
1387
+ if (!res) return transportFail ?? { ok: false, error: 'the model call produced no response' };
1388
+
1389
+ if (!res.ok) {
1390
+ const text = await res.text().catch(() => '');
1391
+ /**
1392
+ * ── ⚠️⚠️ `providerPin` TRAVELS ON THE FAILURE PATH TOO, AND THAT IS THE POINT ─
1393
+ *
1394
+ * It was returned on all three SUCCESS returns and none of the failures, so
1395
+ * the one caller that needs it most could never see it: `callChain` decides
1396
+ * whether a failure is about THIS MODEL or about the REQUEST, and a 404
1397
+ * caused by a bad PIN reads exactly like a 404 caused by a dead model id —
1398
+ * "No endpoints found for <model>", naming the model that was never the
1399
+ * problem.
1400
+ *
1401
+ * ⭐ Measured 2026-08-14: with the pin invisible here, `isModelSpecific`
1402
+ * matched, and the chain spent all four candidates re-sending the identical
1403
+ * bad pin — four round trips and four times the wait to learn one fact
1404
+ * about an environment variable. The chain cannot reason about a cause it
1405
+ * is never told.
1406
+ */
1407
+ return { ok: false, error: classifyHttpFailure(res.status, text, { pin: providerPin }), providerPin };
1408
+ }
1409
+
1410
+ /**
1411
+ * ⚠️ STREAMED AND WHOLE RESPONSES DIVERGE HERE AND NOWHERE ELSE. Both paths
1412
+ * produce the identical reply shape, so the loop, the summary and the chain
1413
+ * never learn which one ran.
1414
+ */
1415
+ if (streaming) {
1416
+ /**
1417
+ * ── ⚠️ `!res.body` WAS A GATE THAT COULD NEVER OPEN ────────────────────────
1418
+ * undici populates `res.body` on EVERY body-bearing response, so the
1419
+ * whole-body fallback below was dead code for as long as it existed — and
1420
+ * the comment above it confidently described behaviour that never ran.
1421
+ * Measured 2026-08-10: a textbook 200 carrying
1422
+ * `{"choices":[{"message":{"content":"ok"}}]}` was fed to the SSE parser,
1423
+ * which found no `data:` lines and reported "the stream closed without
1424
+ * sending anything". That phrase is not in `isRetryable`'s vocabulary, so the
1425
+ * chain STOPPED — on a completion that was correct and already billed.
1426
+ *
1427
+ * ⚠️ THE `ct &&` GUARD IS NOT DEFENSIVE PADDING. A stub or a proxy that sends
1428
+ * no content-type tells us nothing, and inferring "then it must be JSON"
1429
+ * would break real streaming on the strength of a missing header. No header
1430
+ * = today's behaviour, unchanged.
1431
+ */
1432
+ const ct = (typeof res.headers?.get === 'function' ? res.headers.get('content-type') : '') || '';
1433
+ if (!res.body || (ct && !/text\/event-stream/i.test(ct))) {
1434
+ // ⚠️ Not an error — a provider that ignored `stream:true` and sent JSON.
1435
+ // Falling through to the whole-body path is more useful than failing.
1436
+ let whole;
1437
+ try {
1438
+ whole = await res.json();
1439
+ } catch (err) {
1440
+ /**
1441
+ * ⚠️ THE OLD `.catch(() => null)` FLATTENED TWO DIFFERENT WORLDS. A body
1442
+ * that is not JSON is the provider's fault and retrying will produce the
1443
+ * same thing; a body that ABORTED halfway is the network's fault and the
1444
+ * next provider will very likely work. Reporting both as "neither a
1445
+ * stream nor JSON" made the second one non-retryable.
1446
+ */
1447
+ if (err?.name === 'SyntaxError') {
1448
+ return { ok: false, error: 'the provider returned a 200 whose body is neither an event stream nor JSON.' };
1449
+ }
1450
+ return { ok: false, error: describeTransportError(err, timeoutMs), kind: transportErrorKind(err) };
1451
+ }
1452
+ if (!whole) return { ok: false, error: 'the provider returned neither a stream nor JSON.' };
1453
+ const r = extractReply(whole);
1454
+ return r.ok ? { ok: true, ...r, model, providerPin } : { ok: false, error: r.error, providerPin };
1455
+ }
1456
+ /**
1457
+ * ── ⚠️⚠️ THIS AWAIT USED TO BE THE ONE THAT ENDED THE SESSION ─────────────
1458
+ * A dropped socket mid-stream surfaces from undici as `TypeError:
1459
+ * terminated`. Uncaught here it escaped past turn.mjs's deliberate "a
1460
+ * mid-loop model failure is not a whole-session failure" handling, out of
1461
+ * main(), and printed "acuvo crashed — this is a bug in acuvo-code". Exit 1,
1462
+ * and under `--json` stdout was EMPTY — so the whole summary went with it,
1463
+ * including the file round 1 had already written to disk, and no fallback in
1464
+ * the chain was ever tried. `callModel`'s contract is that it never throws;
1465
+ * only the fetch honoured it.
1466
+ */
1467
+ let collected;
1468
+ try {
1469
+ collected = await collectStream(res.body, { onText });
1470
+ } catch (err) {
1471
+ return { ok: false, error: describeTransportError(err, timeoutMs), kind: transportErrorKind(err) };
1472
+ }
1473
+ if (!collected.ok) return { ok: false, error: collected.error };
1474
+ return { ok: true, ...collected, model, providerPin };
1475
+ }
1476
+
1477
+ let body;
1478
+ try {
1479
+ body = await res.json();
1480
+ } catch (err) {
1481
+ // ⚠️ Same trap as the streaming branch above, and it was here too: a body
1482
+ // that aborted mid-read is a TRANSPORT fault, and calling it "not JSON"
1483
+ // told the chain not to bother with the next provider.
1484
+ if (err?.name !== 'SyntaxError') return { ok: false, error: describeTransportError(err, timeoutMs), kind: transportErrorKind(err) };
1485
+ return { ok: false, error: 'OpenRouter returned a 200 with a body that is not JSON.' };
1486
+ }
1487
+ const reply = extractReply(body);
1488
+ if (!reply.ok) return { ok: false, error: reply.error };
1489
+ /**
1490
+ * ── 💰⭐⭐⭐ WHO ACTUALLY SERVED IT — THE MISSING HALF OF THE CACHE STORY ────
1491
+ *
1492
+ * Roman has asked repeatedly how anyone could KNOW the cache will not revert.
1493
+ * The honest answer was that nobody could, because a miss had no explanation:
1494
+ * we recorded the hit RATE and never recorded WHICH UPSTREAM produced it.
1495
+ *
1496
+ * ⭐ AND THE CAUSE IS ROUTING, MEASURED 2026-08-22 AGAINST THE LIVE API. Each
1497
+ * provider keeps its OWN prompt cache, and OpenRouter treats a provider
1498
+ * `order` as a PREFERENCE:
1499
+ *
1500
+ * order:[StreamLake,Baidu,GMICloud] allow_fallbacks:true
1501
+ * -> StreamLake, Baidu, StreamLake (rotates; every switch is a miss)
1502
+ * order:[StreamLake] allow_fallbacks:true
1503
+ * -> Sail Research x4, StreamLake x1 (still leaves the pin)
1504
+ * only:[StreamLake]
1505
+ * -> StreamLake x5 (sticky)
1506
+ *
1507
+ * The warm leg already sends `only`, so the routing is right. What was absent
1508
+ * was the EVIDENCE — `pinOutcome` was written for exactly this comparison and
1509
+ * had no caller anywhere in the package, because nothing captured `served`.
1510
+ *
1511
+ * ⚠️ OPENROUTER RETURNS IT AND WE WERE THROWING IT AWAY. `json.provider` is
1512
+ * on every response; a direct vendor call has no such concept and yields null,
1513
+ * which `pinOutcome` reports as 'none' rather than pretending.
1514
+ */
1515
+ const servedBy = typeof body?.provider === 'string' && body.provider ? body.provider : null;
1516
+ return {
1517
+ ok: true,
1518
+ ...reply,
1519
+ model,
1520
+ providerPin,
1521
+ servedBy,
1522
+ pinOutcome: pinOutcome({ pin: providerPin, served: servedBy }),
1523
+ };
1524
+ }