acuvo-code 0.2.0

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