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.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- 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
|
+
}
|