@askalf/dario 6.0.2 → 6.0.4

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.
@@ -19,7 +19,9 @@
19
19
  * `--model-alias` values, pinned aliases (`opus48`) and catalog family
20
20
  * shorthands (`opus`, `sonnet1m`) map to a canonical id. The caller
21
21
  * supplies that resolver so classification and forwarding cannot
22
- * disagree.
22
+ * disagree. The prefix rule applies to the alias TARGET too, since an
23
+ * operator alias is written the way a request model is written — see
24
+ * stripClaudePrefix.
23
25
  * 2. VALIDATE the resolved id against the catalog base set. A `claude-`
24
26
  * prefix on its own proves nothing — `claude-sonnet-6` has the prefix and
25
27
  * does not exist — so the id (sans `[1m]`) must be a base the pool can
@@ -37,6 +39,23 @@
37
39
  import { BAKED_BASE_MODELS, resolveAliasAgainst } from './model-catalog.js';
38
40
  /** Provider prefixes that force the Claude path (mirrors proxy.ts's PROVIDER_PREFIXES). */
39
41
  const CLAUDE_PREFIXES = new Set(['claude', 'anthropic']);
42
+ /**
43
+ * Strip a Claude-side provider prefix, or refuse the name outright.
44
+ *
45
+ * Null on any prefix that isn't `claude:`/`anthropic:` — a recognized
46
+ * non-Claude prefix is a definite NO, not a fall-through to the name test
47
+ * (`openai:claude-sonnet-5` is the operator pointing somewhere else on
48
+ * purpose), and an unrecognized one names a model this pool has no claim on.
49
+ * `model` is expected already trimmed + lowercased.
50
+ */
51
+ function stripClaudePrefix(model) {
52
+ const idx = model.indexOf(':');
53
+ if (idx <= 0)
54
+ return model || null;
55
+ if (!CLAUDE_PREFIXES.has(model.slice(0, idx)))
56
+ return null;
57
+ return model.slice(idx + 1) || null;
58
+ }
40
59
  /**
41
60
  * The canonical id the Claude pool would serve `model` as, or null when it
42
61
  * cannot serve it. `bases` is the catalog base set — `getCachedBases()` at a
@@ -45,22 +64,22 @@ const CLAUDE_PREFIXES = new Set(['claude', 'anthropic']);
45
64
  * (operator aliases + pinned aliases) where one exists.
46
65
  */
47
66
  export function resolveClaudeServable(model, bases = BAKED_BASE_MODELS, resolve) {
48
- let m = model.trim().toLowerCase();
49
- if (!m)
67
+ const entry = stripClaudePrefix(model.trim().toLowerCase());
68
+ if (entry === null)
69
+ return null;
70
+ // The alias TARGET may carry a prefix of its own — operator aliases are
71
+ // declared the way a request model is written, and `--model-alias=backup=
72
+ // claude:opus` is the natural way to say it (dario#1151). The request path
73
+ // parses that prefix AFTER alias resolution; without the same pass here the
74
+ // target reached the base check as the literal `claude:opus`, matched
75
+ // nothing, and a perfectly valid fallback was skipped.
76
+ const target = stripClaudePrefix((resolve ? resolve(entry) : (resolveAliasAgainst(entry, bases) ?? entry)).trim().toLowerCase());
77
+ if (target === null)
50
78
  return null;
51
- const idx = m.indexOf(':');
52
- if (idx > 0) {
53
- const prefix = m.slice(0, idx);
54
- // A recognized non-Claude prefix is a definite NO, not a fall-through to
55
- // the name test below — `openai:claude-sonnet-5` is the operator pointing
56
- // somewhere else on purpose.
57
- if (!CLAUDE_PREFIXES.has(prefix))
58
- return null;
59
- m = m.slice(idx + 1);
60
- if (!m)
61
- return null;
62
- }
63
- const resolved = (resolve ? resolve(m) : (resolveAliasAgainst(m, bases) ?? m)).trim().toLowerCase();
79
+ // A target that arrived prefixed still holds a shorthand (`claude:opus` →
80
+ // `opus`), so the catalog pass runs on it. Idempotent for a target already
81
+ // canonical: resolveAliasAgainst only answers for family shorthands.
82
+ const resolved = resolveAliasAgainst(target, bases) ?? target;
64
83
  const base = resolved.endsWith('[1m]') ? resolved.slice(0, -4) : resolved;
65
84
  return bases.some((b) => b.toLowerCase() === base) ? resolved : null;
66
85
  }
package/dist/proxy.js CHANGED
@@ -1863,6 +1863,42 @@ export async function startProxy(opts = {}) {
1863
1863
  const hasNextOption = openaiBackend !== null && shape === 'openai';
1864
1864
  return await forwardToCodex(req, res, fallbackBody, creds, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': fallbackModel }, upstreamTimeoutMs, verbose, shape, fetch, hasNextOption);
1865
1865
  };
1866
+ /**
1867
+ * Mid-flight 429, no peer account left to retry — the pool is genuinely
1868
+ * exhausted mid-request, not just at selection. Tries every fallback in
1869
+ * order (subscription, then api-key backend) and returns true once one has
1870
+ * written the response; false means nothing could serve it and the caller
1871
+ * writes its own honest 429.
1872
+ *
1873
+ * A SINGLE function for BOTH mid-flight 429 sites in the request handler
1874
+ * (the non-passthrough recovery-loop path and the general/passthrough
1875
+ * path), by design. Those two sites used to carry independent, hand-copied
1876
+ * versions of this logic, and v6.0.0 wired the fallback into only ONE of
1877
+ * them. The other kept returning the raw upstream 429 — and that unwired
1878
+ * site is the one reached by every non-passthrough request, i.e. everything
1879
+ * except a literal Claude Code CLI session, forge's SDK Engine included.
1880
+ * That is what left the whole fleet unable to fail over during the live
1881
+ * outage on 2026-08-30 while `dario doctor` reported failover armed.
1882
+ *
1883
+ * One function reachable from both call sites is what makes that class of
1884
+ * bug structurally impossible to reintroduce: a future third call site gets
1885
+ * this by construction, not by remembering to copy six lines correctly.
1886
+ */
1887
+ const attemptPoolFallbackOn429 = async (req, res, body, isOpenAI) => {
1888
+ if (await tryCodexPoolFallback(req, res, body, poolFallbackModels, isOpenAI ? 'openai' : 'anthropic', 'pool exhausted mid-flight (429, no peer)')) {
1889
+ return true;
1890
+ }
1891
+ if (isOpenAI && poolFallbackModel && openaiBackend) {
1892
+ const fallbackBody = buildPoolFallbackBody(body, poolFallbackModel);
1893
+ if (fallbackBody) {
1894
+ console.log(`[dario] #${requestCount} pool exhausted mid-flight (429, no peer) → ${openaiBackend.name} as ${poolFallbackModel}`);
1895
+ requestCount++;
1896
+ await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': poolFallbackModel }, upstreamTimeoutMs, verbose);
1897
+ return true;
1898
+ }
1899
+ }
1900
+ return false;
1901
+ };
1866
1902
  const server = createServer(async (req, res) => {
1867
1903
  if (req.method === 'OPTIONS') {
1868
1904
  res.writeHead(204, CORS_HEADERS);
@@ -3590,6 +3626,19 @@ export async function startProxy(opts = {}) {
3590
3626
  continue dispatchLoop;
3591
3627
  }
3592
3628
  }
3629
+ // Pool-exhausted fallback, mid-flight: no peer left to fail over
3630
+ // to. THE BUG (live incident, 2026-08-30) and its independent
3631
+ // rediscovery: this branch — reached by every non-passthrough
3632
+ // request, i.e. everything except a literal Claude Code CLI session
3633
+ // — never called the fallback wired into the OTHER, structurally
3634
+ // near-identical 429 site below. #1153 fixed it here with its own
3635
+ // inline copy; that copy is collapsed into the single shared
3636
+ // attemptPoolFallbackOn429 below, so the file goes back to having
3637
+ // exactly ONE copy of this logic instead of two near-identical ones
3638
+ // — which is the exact shape of bug this incident already was.
3639
+ if (await attemptPoolFallbackOn429(req, res, body, isOpenAI)) {
3640
+ return;
3641
+ }
3593
3642
  const enriched = enrich429(peekedBody, upstream.headers);
3594
3643
  const responseHeaders = {
3595
3644
  'Content-Type': 'application/json',
@@ -3680,25 +3729,14 @@ export async function startProxy(opts = {}) {
3680
3729
  continue dispatchLoop;
3681
3730
  }
3682
3731
  }
3683
- // Pool-exhausted fallback: no peer left to fail over to. For an
3684
- // OpenAI-shape request with the fallback armed, re-point the
3685
- // client's own body at the openai-compat backend instead of
3686
- // surfacing the 429. `body` still holds the client's OpenAI-shape
3687
- // bytes the Anthropic translation went into finalBody, never
3688
- // back into body. Marked via x-dario-pool-fallback, same as the
3689
- // selection-time path.
3690
- if (await tryCodexPoolFallback(req, res, body, poolFallbackModels, isOpenAI ? 'openai' : 'anthropic', 'pool exhausted mid-flight (429, no peer)')) {
3732
+ // Pool-exhausted fallback: no peer left to fail over to. `body`
3733
+ // still holds the client's own bytes as received — the Anthropic
3734
+ // translation (OpenAI-shape clients) went into finalBody, never back
3735
+ // into body. See attemptPoolFallbackOn429's doc comment for why this
3736
+ // is one shared function rather than an inline copy.
3737
+ if (await attemptPoolFallbackOn429(req, res, body, isOpenAI)) {
3691
3738
  return;
3692
3739
  }
3693
- if (isOpenAI && poolFallbackModel && openaiBackend) {
3694
- const fallbackBody = buildPoolFallbackBody(body, poolFallbackModel);
3695
- if (fallbackBody) {
3696
- console.log(`[dario] #${requestCount} pool exhausted mid-flight (429, no peer) — /v1/chat/completions → ${openaiBackend.name} as ${poolFallbackModel}`);
3697
- requestCount++;
3698
- await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': poolFallbackModel }, upstreamTimeoutMs, verbose);
3699
- return;
3700
- }
3701
- }
3702
3740
  const errBody = await upstream.text().catch(() => '');
3703
3741
  const enriched = enrich429(errBody, upstream.headers);
3704
3742
  const responseHeaders = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.0.2",
3
+ "version": "6.0.4",
4
4
  "description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
5
5
  "type": "module",
6
6
  "bin": {