@askalf/dario 6.14.3 → 6.15.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/dist/proxy.d.ts CHANGED
@@ -126,6 +126,13 @@ export declare function parseProviderPrefix(model: string): {
126
126
  * Exported for tests.
127
127
  */
128
128
  export declare const FABLE_FALLBACK_CREDIT_BETA = "fallback-credit-2026-06-01";
129
+ /**
130
+ * Marks the request the family fallback sends again through dario's own front
131
+ * door after a mid-flight 429 (see familyFallbackOn429 in startProxy). A
132
+ * request carrying it is never sent again itself, so the fallback takes one
133
+ * hop. A client that sends it only turns that re-send off for its request.
134
+ */
135
+ export declare const FAMILY_FALLBACK_LEG_HEADER = "x-dario-family-fallback-leg";
129
136
  export declare const CONTEXT_1M_BETA = "context-1m-2025-08-07";
130
137
  export declare const MID_CONVERSATION_SYSTEM_BETA = "mid-conversation-system-2026-04-07";
131
138
  export declare const EFFORT_BETA = "effort-2025-11-24";
package/dist/proxy.js CHANGED
@@ -497,6 +497,13 @@ function filterBillableBetas(betas) {
497
497
  * Exported for tests.
498
498
  */
499
499
  export const FABLE_FALLBACK_CREDIT_BETA = 'fallback-credit-2026-06-01';
500
+ /**
501
+ * Marks the request the family fallback sends again through dario's own front
502
+ * door after a mid-flight 429 (see familyFallbackOn429 in startProxy). A
503
+ * request carrying it is never sent again itself, so the fallback takes one
504
+ * hop. A client that sends it only turns that re-send off for its request.
505
+ */
506
+ export const FAMILY_FALLBACK_LEG_HEADER = 'x-dario-family-fallback-leg';
500
507
  export const CONTEXT_1M_BETA = 'context-1m-2025-08-07';
501
508
  export const MID_CONVERSATION_SYSTEM_BETA = 'mid-conversation-system-2026-04-07';
502
509
  export const EFFORT_BETA = 'effort-2025-11-24';
@@ -3646,6 +3653,95 @@ export async function startProxy(opts = {}) {
3646
3653
  return null;
3647
3654
  }
3648
3655
  };
3656
+ /**
3657
+ * Family fallback, mid-flight (family-fallback.ts). The 429 that just
3658
+ * came back can be the one that parks the family: the seat's included
3659
+ * Fable credit ran out on this request, or dario restarted and has not
3660
+ * yet seen the credit spent (the pool's readings live in memory). The
3661
+ * fallback runs before selection, so this request missed it, and would
3662
+ * go on to the codex fallback or back to the client as a 429.
3663
+ *
3664
+ * Instead it is sent again, as the client wrote it, through dario's own
3665
+ * front door. That request meets the family parked, so the fallback
3666
+ * re-points it and the whole Claude path builds it for the target model,
3667
+ * with that model's template, betas and effort. Rewriting the model on
3668
+ * the body already built for Fable would send Opus a Fable request.
3669
+ *
3670
+ * One hop: the re-sent request carries FAMILY_FALLBACK_LEG_HEADER and is
3671
+ * never sent again itself. Skipped wherever the fallback before selection
3672
+ * is skipped, for a continuation leg, and for a request already re-pointed.
3673
+ * The queue slot is released first, as a continuation does, so a
3674
+ * one-slot proxy does not wait on itself. True when the client was answered.
3675
+ */
3676
+ const familyFallbackOn429 = async (rejected) => {
3677
+ if (familyFallback.size === 0 || upstreamApiKey || pinnedAccount || modelOverride || fastModelOverride)
3678
+ return false;
3679
+ if (isResponses || isCountTokens || requestDepth > 0 || req.headers[FAMILY_FALLBACK_LEG_HEADER] !== undefined)
3680
+ return false;
3681
+ if (res.headersSent || res.getHeader('x-dario-family-fallback') !== undefined)
3682
+ return false;
3683
+ const fromModel = parseClientBody()?.model;
3684
+ if (typeof fromModel !== 'string')
3685
+ return false;
3686
+ const now = Date.now();
3687
+ const toModel = familyFallbackModel(familyFallback, fromModel, {
3688
+ parked: (f) => pool.familyRateLimited(f, now),
3689
+ servable: (f) => pool.canServe(f, now),
3690
+ });
3691
+ if (!toModel)
3692
+ return false;
3693
+ void rejected.body?.cancel().catch(() => { });
3694
+ console.log(`[dario] #${requestCount} the 429 parked every seat for ${fromModel} -> sent again as ${toModel} (family fallback)`);
3695
+ releaseQueueSlot();
3696
+ const abort = new AbortController();
3697
+ const onClose = () => { if (!res.writableEnded)
3698
+ abort.abort(); };
3699
+ res.on('close', onClose);
3700
+ const timer = setTimeout(() => abort.abort(), upstreamTimeoutMs);
3701
+ try {
3702
+ const leg = await fetch(`${loopbackBase}${isOpenAI ? '/v1/chat/completions' : '/v1/messages'}`, {
3703
+ method: 'POST',
3704
+ headers: { 'content-type': 'application/json', [FAMILY_FALLBACK_LEG_HEADER]: '1', ...loopbackHeaders() },
3705
+ body: new Uint8Array(clientBodyBytes),
3706
+ signal: abort.signal,
3707
+ });
3708
+ const responseHeaders = { 'Access-Control-Allow-Origin': corsOrigin, ...SECURITY_HEADERS };
3709
+ for (const [key, value] of leg.headers.entries()) {
3710
+ if (key === 'content-type' || key === 'retry-after' || key === 'request-id' || key.startsWith('anthropic-ratelimit') || key.startsWith('x-dario-')) {
3711
+ responseHeaders[key] = value;
3712
+ }
3713
+ }
3714
+ responseHeaders['x-dario-family-fallback'] = fromModel;
3715
+ res.writeHead(leg.status, responseHeaders);
3716
+ if (leg.body) {
3717
+ const reader = leg.body.getReader();
3718
+ while (true) {
3719
+ const { done, value } = await reader.read();
3720
+ if (done)
3721
+ break;
3722
+ if (!res.write(value))
3723
+ await new Promise((resolve) => { res.once('drain', resolve); res.once('close', resolve); });
3724
+ }
3725
+ }
3726
+ if (!res.writableEnded)
3727
+ res.end();
3728
+ }
3729
+ catch (err) {
3730
+ console.error(`[dario] #${requestCount} family fallback: the re-sent request failed: ${sanitizeError(err)}`);
3731
+ if (!res.headersSent) {
3732
+ res.writeHead(502, JSON_HEADERS);
3733
+ res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: 'family fallback: the re-sent request failed' } }));
3734
+ }
3735
+ else if (!res.writableEnded) {
3736
+ res.end();
3737
+ }
3738
+ }
3739
+ finally {
3740
+ clearTimeout(timer);
3741
+ res.off('close', onClose);
3742
+ }
3743
+ return true;
3744
+ };
3649
3745
  /**
3650
3746
  * Where a Claude stream that died mid-way resumes: the codex half of the
3651
3747
  * `--pool-fallback` chain, exactly the entry a mid-flight 429 would use.
@@ -5424,6 +5520,8 @@ export async function startProxy(opts = {}) {
5424
5520
  // The Claude half is out of quota for this request AND for the next
5425
5521
  // one — cool it here, honouring the upstream `retry-after` when it
5426
5522
  // sent one, so the following request does not re-walk the chain.
5523
+ if (await familyFallbackOn429(upstream))
5524
+ return;
5427
5525
  attemptedProviders.add('claude');
5428
5526
  providerCooldowns.note('claude', parseRetryAfterMs(upstream.headers.get('retry-after')));
5429
5527
  if (!pinnedAccount && await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders, { queueMs, arrivedAt })) {
@@ -5552,6 +5650,8 @@ export async function startProxy(opts = {}) {
5552
5650
  // into body. See attemptPoolFallbackOn429's doc comment for why this
5553
5651
  // is one shared function rather than an inline copy.
5554
5652
  // Same bookkeeping as the other mid-flight site — see there.
5653
+ if (await familyFallbackOn429(upstream))
5654
+ return;
5555
5655
  attemptedProviders.add('claude');
5556
5656
  providerCooldowns.note('claude', parseRetryAfterMs(upstream.headers.get('retry-after')));
5557
5657
  if (!pinnedAccount && await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders, { queueMs, arrivedAt })) {
@@ -96,7 +96,7 @@ dario proxy --family-fallback=fable=claude-opus-5-5
96
96
  - **Not for.** Upstream API-key mode (no pool), a server-wide `--model`/`--fast-model` (which replaces the name anyway) and a seat-pinned request (`x-dario-account`), which asked for that seat as it is.
97
97
  - **Shape.** The source is a family (`opus`, `sonnet`, `haiku`, `fable`); the target a `claude-*` model of another family. One step, never chained. Bad entries are dropped with a warning at startup and in `dario doctor`. A `[1m]` tag on the request carries over to a target without one.
98
98
 
99
- The first Fable request after the credit runs out still goes upstream and comes back `429`: that answer is what parks Fable on the seat. Every Fable request after it is switched, including after the seat has served Opus since.
99
+ The first Fable request after the credit runs out still goes upstream and comes back `429`: that answer is what parks Fable on the seat. dario does not hand that `429` to the client: it sends the same request again, as the client wrote it, through its own front door, where the fallback now applies and the request is built for the target model (its own template, betas and effort). So is the first Fable request after a restart, which is when dario has not seen the credit spent yet. That re-sent request is never re-sent itself, and it does not wait on its own queue slot. Every Fable request after it is switched before it is sent, including after the seat has served Opus since.
100
100
 
101
101
  ## In-flight 429 failover
102
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.14.3",
3
+ "version": "6.15.0",
4
4
  "description": "Use your Claude and ChatGPT subscriptions in Cursor, Cline, Aider, Claude Code and the Agent SDK — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint: either plan answers either wire shape, with automatic failover when one hits its limit.",
5
5
  "type": "module",
6
6
  "bin": {