@askalf/dario 5.2.5 → 5.2.7
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/README.md +1 -0
- package/dist/accounts.js +8 -14
- package/dist/cc-template-data.json +3 -3
- package/dist/cli.js +88 -5
- package/dist/config-file.d.ts +26 -0
- package/dist/config-file.js +28 -0
- package/dist/durable-write.d.ts +17 -0
- package/dist/durable-write.js +94 -0
- package/dist/live-fingerprint.d.ts +1 -1
- package/dist/live-fingerprint.js +1 -1
- package/dist/oauth.js +9 -5
- package/dist/pool.d.ts +28 -0
- package/dist/pool.js +53 -0
- package/dist/provider-adapter.d.ts +74 -0
- package/dist/provider-adapter.js +76 -0
- package/dist/proxy.d.ts +56 -0
- package/dist/proxy.js +257 -27
- package/dist/tui/tabs/config.js +2 -0
- package/package.json +2 -2
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider routing seam.
|
|
3
|
+
*
|
|
4
|
+
* dario's "which backend owns this request" decision used to live inline in the
|
|
5
|
+
* request handler as a set of interleaved conditions (path class, provider
|
|
6
|
+
* prefix, GPT-family model test, the openai-backend reroute guard, the pool
|
|
7
|
+
* fallback guard). This module consolidates that decision into one place: a
|
|
8
|
+
* small set of `ProviderAdapter`s and a `route()` function that returns the
|
|
9
|
+
* primary provider plus any exhaustion fallback.
|
|
10
|
+
*
|
|
11
|
+
* Scope is deliberately the DECISION, not the request lifecycle. The Claude
|
|
12
|
+
* path's pool/template/cch/session/overage machinery stays shared below this
|
|
13
|
+
* seam — it's infrastructure that happens to serve one provider, not per-
|
|
14
|
+
* provider behaviour, so pushing it behind an adapter interface would make the
|
|
15
|
+
* Claude adapter the whole proxy and the OpenAI adapter nearly empty. The seam
|
|
16
|
+
* that pays for itself is routing + request-shaping; the rest is shared.
|
|
17
|
+
*
|
|
18
|
+
* The adapters reuse the same primitive proxy.ts uses (`isOpenAIModel`), so this
|
|
19
|
+
* is a consolidation of the existing decision, not a re-derivation of it.
|
|
20
|
+
*/
|
|
21
|
+
export type ProviderId = 'claude' | 'openai';
|
|
22
|
+
/** Inputs the routing decision needs, computed once per request. */
|
|
23
|
+
export interface RouteContext {
|
|
24
|
+
/** urlPath === '/v1/chat/completions' (OpenAI chat shape). */
|
|
25
|
+
isOpenAIPath: boolean;
|
|
26
|
+
/** Model name after provider-prefix stripping (e.g. 'gpt-4o', 'claude-opus-4-8'). */
|
|
27
|
+
model: string;
|
|
28
|
+
/** Forced provider from a `<provider>:` prefix or `--model` override; null if unforced. */
|
|
29
|
+
forcedProvider: ProviderId | null;
|
|
30
|
+
/** An openai-compat backend is configured (`dario backend add …`). */
|
|
31
|
+
hasOpenAIBackend: boolean;
|
|
32
|
+
/** `--pool-fallback=<model>` value, or null when disabled. */
|
|
33
|
+
poolFallbackModel: string | null;
|
|
34
|
+
/** Live pool account count. */
|
|
35
|
+
poolSize: number;
|
|
36
|
+
}
|
|
37
|
+
export interface RouteDecision {
|
|
38
|
+
/** Primary handler for the request. */
|
|
39
|
+
provider: ProviderId;
|
|
40
|
+
/** Provider to fall to on primary exhaustion; only claude→openai exists today. */
|
|
41
|
+
fallback: ProviderId | null;
|
|
42
|
+
/** Human-readable trace for `--verbose` and tests. */
|
|
43
|
+
reason: string;
|
|
44
|
+
}
|
|
45
|
+
export interface ProviderAdapter {
|
|
46
|
+
id: ProviderId;
|
|
47
|
+
/** Higher priority is offered the request first. */
|
|
48
|
+
priority: number;
|
|
49
|
+
/** True if this adapter should PRIMARILY handle the request. */
|
|
50
|
+
claimsPrimary(ctx: RouteContext): boolean;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* OpenAI-compat backend adapter. Claims a request under exactly the condition
|
|
54
|
+
* the request handler reroutes on: a configured backend, an OpenAI-shape
|
|
55
|
+
* request, not force-routed to Claude, and either force-routed to openai or a
|
|
56
|
+
* recognized GPT-family model.
|
|
57
|
+
*/
|
|
58
|
+
export declare const openaiAdapter: ProviderAdapter;
|
|
59
|
+
/**
|
|
60
|
+
* Claude adapter — the default owner. Claims anything the openai adapter
|
|
61
|
+
* doesn't, matching the request handler's fall-through to the template path
|
|
62
|
+
* (including OpenAI-shape requests with Claude models, which the Claude path
|
|
63
|
+
* serves via openai→anthropic translation).
|
|
64
|
+
*/
|
|
65
|
+
export declare const claudeAdapter: ProviderAdapter;
|
|
66
|
+
export declare const DEFAULT_ADAPTERS: readonly ProviderAdapter[];
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the routing decision. Offers the request to adapters in priority
|
|
69
|
+
* order and takes the first primary claim; the Claude adapter always claims, so
|
|
70
|
+
* the result is total. The claude→openai pool fallback is layered on top
|
|
71
|
+
* because it's a cross-adapter relationship (a Claude-primary request that
|
|
72
|
+
* spills to openai on pool exhaustion), not a primary claim by either side.
|
|
73
|
+
*/
|
|
74
|
+
export declare function route(ctx: RouteContext, adapters?: readonly ProviderAdapter[]): RouteDecision;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider routing seam.
|
|
3
|
+
*
|
|
4
|
+
* dario's "which backend owns this request" decision used to live inline in the
|
|
5
|
+
* request handler as a set of interleaved conditions (path class, provider
|
|
6
|
+
* prefix, GPT-family model test, the openai-backend reroute guard, the pool
|
|
7
|
+
* fallback guard). This module consolidates that decision into one place: a
|
|
8
|
+
* small set of `ProviderAdapter`s and a `route()` function that returns the
|
|
9
|
+
* primary provider plus any exhaustion fallback.
|
|
10
|
+
*
|
|
11
|
+
* Scope is deliberately the DECISION, not the request lifecycle. The Claude
|
|
12
|
+
* path's pool/template/cch/session/overage machinery stays shared below this
|
|
13
|
+
* seam — it's infrastructure that happens to serve one provider, not per-
|
|
14
|
+
* provider behaviour, so pushing it behind an adapter interface would make the
|
|
15
|
+
* Claude adapter the whole proxy and the OpenAI adapter nearly empty. The seam
|
|
16
|
+
* that pays for itself is routing + request-shaping; the rest is shared.
|
|
17
|
+
*
|
|
18
|
+
* The adapters reuse the same primitive proxy.ts uses (`isOpenAIModel`), so this
|
|
19
|
+
* is a consolidation of the existing decision, not a re-derivation of it.
|
|
20
|
+
*/
|
|
21
|
+
import { isOpenAIModel } from './openai-backend.js';
|
|
22
|
+
/**
|
|
23
|
+
* OpenAI-compat backend adapter. Claims a request under exactly the condition
|
|
24
|
+
* the request handler reroutes on: a configured backend, an OpenAI-shape
|
|
25
|
+
* request, not force-routed to Claude, and either force-routed to openai or a
|
|
26
|
+
* recognized GPT-family model.
|
|
27
|
+
*/
|
|
28
|
+
export const openaiAdapter = {
|
|
29
|
+
id: 'openai',
|
|
30
|
+
priority: 100,
|
|
31
|
+
claimsPrimary(ctx) {
|
|
32
|
+
if (!ctx.hasOpenAIBackend)
|
|
33
|
+
return false;
|
|
34
|
+
if (!ctx.isOpenAIPath)
|
|
35
|
+
return false;
|
|
36
|
+
if (ctx.forcedProvider === 'claude')
|
|
37
|
+
return false;
|
|
38
|
+
return ctx.forcedProvider === 'openai' || isOpenAIModel(ctx.model);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Claude adapter — the default owner. Claims anything the openai adapter
|
|
43
|
+
* doesn't, matching the request handler's fall-through to the template path
|
|
44
|
+
* (including OpenAI-shape requests with Claude models, which the Claude path
|
|
45
|
+
* serves via openai→anthropic translation).
|
|
46
|
+
*/
|
|
47
|
+
export const claudeAdapter = {
|
|
48
|
+
id: 'claude',
|
|
49
|
+
priority: 0,
|
|
50
|
+
claimsPrimary() {
|
|
51
|
+
return true;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
export const DEFAULT_ADAPTERS = [openaiAdapter, claudeAdapter];
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the routing decision. Offers the request to adapters in priority
|
|
57
|
+
* order and takes the first primary claim; the Claude adapter always claims, so
|
|
58
|
+
* the result is total. The claude→openai pool fallback is layered on top
|
|
59
|
+
* because it's a cross-adapter relationship (a Claude-primary request that
|
|
60
|
+
* spills to openai on pool exhaustion), not a primary claim by either side.
|
|
61
|
+
*/
|
|
62
|
+
export function route(ctx, adapters = DEFAULT_ADAPTERS) {
|
|
63
|
+
const ordered = [...adapters].sort((a, b) => b.priority - a.priority);
|
|
64
|
+
const primary = ordered.find((a) => a.claimsPrimary(ctx)) ?? claudeAdapter;
|
|
65
|
+
let fallback = null;
|
|
66
|
+
let reason = `${primary.id} primary`;
|
|
67
|
+
if (primary.id === 'claude' &&
|
|
68
|
+
ctx.poolFallbackModel !== null &&
|
|
69
|
+
ctx.hasOpenAIBackend &&
|
|
70
|
+
ctx.isOpenAIPath &&
|
|
71
|
+
ctx.poolSize > 0) {
|
|
72
|
+
fallback = 'openai';
|
|
73
|
+
reason = 'claude primary, openai fallback on pool-exhaustion';
|
|
74
|
+
}
|
|
75
|
+
return { provider: primary.id, fallback, reason };
|
|
76
|
+
}
|
package/dist/proxy.d.ts
CHANGED
|
@@ -37,6 +37,24 @@ export declare function buildBillingTag(cliVersion: string, cch: string | null):
|
|
|
37
37
|
* that needs to Just Work.
|
|
38
38
|
*/
|
|
39
39
|
export declare function resolveClaudeAlias(model: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* User-defined model aliases: client-visible name → target model.
|
|
42
|
+
*
|
|
43
|
+
* Complements the built-in family shorthands above — those track the model
|
|
44
|
+
* catalog; these are operator-declared (config `modelAliases`, repeatable
|
|
45
|
+
* `--model-alias=name=target`, `DARIO_MODEL_ALIASES=name=target,…`) and
|
|
46
|
+
* resolve FIRST at request time, before provider-prefix parsing, so a
|
|
47
|
+
* target may carry a prefix (`my-fast` → `openai:gpt-4o-mini`) and
|
|
48
|
+
* retarget the backend. One step, never recursive: a target that names
|
|
49
|
+
* another alias is forwarded as-is. Alias names match case-insensitively;
|
|
50
|
+
* targets forward verbatim. An alias may shadow a real model id or a
|
|
51
|
+
* built-in shorthand — deliberate, that's how you downgrade every `opus`
|
|
52
|
+
* call from a client whose picker you don't control.
|
|
53
|
+
*/
|
|
54
|
+
export declare function parseModelAliasSpecs(specs: readonly string[]): Record<string, string>;
|
|
55
|
+
/** Resolve `model` through user aliases. Null = no alias applies (also on
|
|
56
|
+
* self-mapping, so a misconfigured `opus=opus` can't loop the caller). */
|
|
57
|
+
export declare function applyModelAlias(model: string | null | undefined, aliases: Record<string, string> | undefined): string | null;
|
|
40
58
|
/**
|
|
41
59
|
* Pick the per-request model override under a forced `--model`.
|
|
42
60
|
*
|
|
@@ -52,6 +70,13 @@ export declare function resolveClaudeAlias(model: string): string;
|
|
|
52
70
|
* change is inert until the operator opts in.
|
|
53
71
|
*/
|
|
54
72
|
export declare function selectModelOverride(incomingModel: string, modelOverride: string | null, fastModelOverride: string | null): string | null;
|
|
73
|
+
/**
|
|
74
|
+
* Rebuild an OpenAI-shape request body with the model swapped to the pool-
|
|
75
|
+
* fallback target. Null when the body isn't a JSON object — the caller
|
|
76
|
+
* surfaces the original error instead of forwarding garbage. Exported for
|
|
77
|
+
* tests; the two call sites are the pool-exhausted dispatch paths.
|
|
78
|
+
*/
|
|
79
|
+
export declare function buildPoolFallbackBody(body: Buffer, fallbackModel: string): Buffer | null;
|
|
55
80
|
export declare function parseProviderPrefix(model: string): {
|
|
56
81
|
provider: 'openai' | 'claude';
|
|
57
82
|
model: string;
|
|
@@ -288,6 +313,16 @@ interface ProxyOptions {
|
|
|
288
313
|
* --strict-tls. dario#77.
|
|
289
314
|
*/
|
|
290
315
|
strictTemplate?: boolean;
|
|
316
|
+
/**
|
|
317
|
+
* Pool routing strategy. `headroom` (default) spreads new conversations
|
|
318
|
+
* to the seat with the most headroom; `fill-first` concentrates them on
|
|
319
|
+
* the alphabetically-first eligible seat until it drains to the 2%
|
|
320
|
+
* floor, then spills to the next — primary/backup semantics where a
|
|
321
|
+
* `z-backup` seat stays untouched until `a-main` is actually drained.
|
|
322
|
+
* Sticky bindings behave identically in both modes. Sourced from
|
|
323
|
+
* `--pool-strategy` / `DARIO_POOL_STRATEGY` / config `pool.strategy`.
|
|
324
|
+
*/
|
|
325
|
+
poolStrategy?: string;
|
|
291
326
|
/** Max concurrent in-flight requests. Default 10. dario#80. */
|
|
292
327
|
maxConcurrent?: number;
|
|
293
328
|
/** Max requests buffered waiting for a concurrency slot. Default 128. dario#80. */
|
|
@@ -312,6 +347,27 @@ interface ProxyOptions {
|
|
|
312
347
|
* their output capacity. dario#88 (Hermes compat).
|
|
313
348
|
*/
|
|
314
349
|
maxTokens?: number | 'client';
|
|
350
|
+
/**
|
|
351
|
+
* Pool-exhausted fallback model (strictly opt-in; off when unset/empty).
|
|
352
|
+
* When the Claude pool can't serve — selection finds every seat drained
|
|
353
|
+
* or cooling, or a mid-flight 429 has no peer left — OpenAI-shape
|
|
354
|
+
* requests (/v1/chat/completions) are forwarded to the configured
|
|
355
|
+
* openai-compat backend with the model swapped to this value, instead
|
|
356
|
+
* of surfacing the 429/503. Responses carry `x-dario-pool-fallback`.
|
|
357
|
+
* Anthropic-shape requests keep the error: dario has no OpenAI→Anthropic
|
|
358
|
+
* response translation. Inert without a configured backend. Sourced from
|
|
359
|
+
* `--pool-fallback` / `DARIO_POOL_FALLBACK` / config `poolFallback.model`.
|
|
360
|
+
*/
|
|
361
|
+
poolFallbackModel?: string;
|
|
362
|
+
/**
|
|
363
|
+
* User-defined model aliases, client-visible name (lowercase) → target.
|
|
364
|
+
* Resolved per request BEFORE provider-prefix parsing (a target may
|
|
365
|
+
* carry a prefix and retarget the backend) and advertised on
|
|
366
|
+
* /v1/models. One step, never recursive. See parseModelAliasSpecs.
|
|
367
|
+
* Sourced from config `modelAliases` < `DARIO_MODEL_ALIASES` <
|
|
368
|
+
* repeatable `--model-alias=name=target`, merged per-key by the CLI.
|
|
369
|
+
*/
|
|
370
|
+
modelAliases?: Record<string, string>;
|
|
315
371
|
/**
|
|
316
372
|
* Append-only request log file. One JSON line per completed request,
|
|
317
373
|
* with secrets scrubbed via redactSecrets. Useful for backgrounded
|
package/dist/proxy.js
CHANGED
|
@@ -12,14 +12,15 @@ import { darioVersion } from './version.js';
|
|
|
12
12
|
import { buildCCRequest, applyCcPromptCaching, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, isMcpToolName, CC_TEMPLATE, effectiveCacheControl, withForced1hBeta } from './cc-template.js';
|
|
13
13
|
import { stampCch, hasCchSeed } from './cch.js';
|
|
14
14
|
import { describeTemplate, detectDrift, checkCCCompat } from './live-fingerprint.js';
|
|
15
|
-
import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, reconcilePoolAccounts } from './pool.js';
|
|
15
|
+
import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, reconcilePoolAccounts, resolvePoolStrategy } from './pool.js';
|
|
16
16
|
import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS } from './analytics.js';
|
|
17
17
|
import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
|
|
18
18
|
import { notify as osNotify } from './notify.js';
|
|
19
|
-
import { loadAllAccounts, loadAccount, refreshAccountToken, resyncLoginFromCredentialsIfStale, ensureLoginCredentialsInPool } from './accounts.js';
|
|
19
|
+
import { loadAllAccounts, loadAccount, saveAccount, refreshAccountToken, resyncLoginFromCredentialsIfStale, ensureLoginCredentialsInPool } from './accounts.js';
|
|
20
20
|
import { handleAdminRequest } from './admin-api.js';
|
|
21
21
|
import { createTokenBucket } from './rate-limit.js';
|
|
22
22
|
import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
|
|
23
|
+
import { route as routeProvider } from './provider-adapter.js';
|
|
23
24
|
import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
|
|
24
25
|
import { redactSecrets } from './redact.js';
|
|
25
26
|
import { BAKED_BASE_MODELS, withLongContextVariants, buildOpenAIModelsList, getModelCatalog, getCachedBases, resolveAliasAgainst, prewarmModelCatalog, retryModelCatalogNow, isSuspendedModel } from './model-catalog.js';
|
|
@@ -235,6 +236,44 @@ const MODEL_ALIASES = {
|
|
|
235
236
|
export function resolveClaudeAlias(model) {
|
|
236
237
|
return resolveAliasAgainst(model, getCachedBases()) ?? MODEL_ALIASES[model] ?? model;
|
|
237
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* User-defined model aliases: client-visible name → target model.
|
|
241
|
+
*
|
|
242
|
+
* Complements the built-in family shorthands above — those track the model
|
|
243
|
+
* catalog; these are operator-declared (config `modelAliases`, repeatable
|
|
244
|
+
* `--model-alias=name=target`, `DARIO_MODEL_ALIASES=name=target,…`) and
|
|
245
|
+
* resolve FIRST at request time, before provider-prefix parsing, so a
|
|
246
|
+
* target may carry a prefix (`my-fast` → `openai:gpt-4o-mini`) and
|
|
247
|
+
* retarget the backend. One step, never recursive: a target that names
|
|
248
|
+
* another alias is forwarded as-is. Alias names match case-insensitively;
|
|
249
|
+
* targets forward verbatim. An alias may shadow a real model id or a
|
|
250
|
+
* built-in shorthand — deliberate, that's how you downgrade every `opus`
|
|
251
|
+
* call from a client whose picker you don't control.
|
|
252
|
+
*/
|
|
253
|
+
export function parseModelAliasSpecs(specs) {
|
|
254
|
+
const out = {};
|
|
255
|
+
for (const spec of specs) {
|
|
256
|
+
const idx = spec.indexOf('=');
|
|
257
|
+
if (idx <= 0)
|
|
258
|
+
continue;
|
|
259
|
+
const name = spec.slice(0, idx).trim().toLowerCase();
|
|
260
|
+
const target = spec.slice(idx + 1).trim();
|
|
261
|
+
if (!name || !target)
|
|
262
|
+
continue;
|
|
263
|
+
out[name] = target;
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
/** Resolve `model` through user aliases. Null = no alias applies (also on
|
|
268
|
+
* self-mapping, so a misconfigured `opus=opus` can't loop the caller). */
|
|
269
|
+
export function applyModelAlias(model, aliases) {
|
|
270
|
+
if (!aliases || !model)
|
|
271
|
+
return null;
|
|
272
|
+
const target = aliases[model.trim().toLowerCase()];
|
|
273
|
+
if (target === undefined || target === model)
|
|
274
|
+
return null;
|
|
275
|
+
return target;
|
|
276
|
+
}
|
|
238
277
|
/**
|
|
239
278
|
* Pick the per-request model override under a forced `--model`.
|
|
240
279
|
*
|
|
@@ -268,6 +307,24 @@ const PROVIDER_PREFIXES = {
|
|
|
268
307
|
claude: 'claude',
|
|
269
308
|
anthropic: 'claude',
|
|
270
309
|
};
|
|
310
|
+
/**
|
|
311
|
+
* Rebuild an OpenAI-shape request body with the model swapped to the pool-
|
|
312
|
+
* fallback target. Null when the body isn't a JSON object — the caller
|
|
313
|
+
* surfaces the original error instead of forwarding garbage. Exported for
|
|
314
|
+
* tests; the two call sites are the pool-exhausted dispatch paths.
|
|
315
|
+
*/
|
|
316
|
+
export function buildPoolFallbackBody(body, fallbackModel) {
|
|
317
|
+
try {
|
|
318
|
+
const parsed = JSON.parse(body.toString());
|
|
319
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
320
|
+
return null;
|
|
321
|
+
parsed.model = fallbackModel;
|
|
322
|
+
return Buffer.from(JSON.stringify(parsed));
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
271
328
|
export function parseProviderPrefix(model) {
|
|
272
329
|
const idx = model.indexOf(':');
|
|
273
330
|
if (idx <= 0)
|
|
@@ -1035,6 +1092,30 @@ export async function startProxy(opts = {}) {
|
|
|
1035
1092
|
if (openaiBackend) {
|
|
1036
1093
|
console.log(` OpenAI-compat backend: ${openaiBackend.name} → ${openaiBackend.baseUrl}`);
|
|
1037
1094
|
}
|
|
1095
|
+
// Pool-exhausted fallback (strictly opt-in). When the Claude pool can't
|
|
1096
|
+
// serve — every seat rate-limited or in auth cool-down — OpenAI-shape
|
|
1097
|
+
// requests (/v1/chat/completions) are re-pointed at the configured
|
|
1098
|
+
// openai-compat backend with the model swapped to `poolFallbackModel`,
|
|
1099
|
+
// instead of surfacing the 429/503. Anthropic-shape requests keep the
|
|
1100
|
+
// error: dario has no OpenAI→Anthropic response translation, and
|
|
1101
|
+
// half-translating would corrupt streaming clients. Every substituted
|
|
1102
|
+
// response carries `x-dario-pool-fallback: <model>` — a silently swapped
|
|
1103
|
+
// model is the kind of surprise this project exists to avoid.
|
|
1104
|
+
const poolFallbackModel = (opts.poolFallbackModel ?? '').trim() || null;
|
|
1105
|
+
if (poolFallbackModel && openaiBackend) {
|
|
1106
|
+
console.log(` Pool fallback: exhausted-pool /v1/chat/completions requests → ${openaiBackend.name} as ${poolFallbackModel} (marked x-dario-pool-fallback)`);
|
|
1107
|
+
}
|
|
1108
|
+
else if (poolFallbackModel && !openaiBackend) {
|
|
1109
|
+
console.warn('[dario] --pool-fallback is set but no OpenAI-compat backend is configured (`dario backend add …`) — fallback is inert.');
|
|
1110
|
+
}
|
|
1111
|
+
// User-defined model aliases (see parseModelAliasSpecs). Resolved by the
|
|
1112
|
+
// CLI (config < env < flags, per-key) and applied per request before
|
|
1113
|
+
// provider-prefix parsing; also advertised on /v1/models so client model
|
|
1114
|
+
// pickers can offer them.
|
|
1115
|
+
const modelAliases = opts.modelAliases ?? {};
|
|
1116
|
+
if (Object.keys(modelAliases).length > 0) {
|
|
1117
|
+
console.log(` Model aliases: ${Object.entries(modelAliases).map(([k, v]) => `${k} → ${v}`).join(', ')}`);
|
|
1118
|
+
}
|
|
1038
1119
|
// Pool-as-primitive (v5.0). The account pool is the one credential model:
|
|
1039
1120
|
// a plain `dario login` is a pool of one under the reserved `login` alias,
|
|
1040
1121
|
// and a pool of many is the same path with more members. There is no
|
|
@@ -1063,7 +1144,11 @@ export async function startProxy(opts = {}) {
|
|
|
1063
1144
|
// POST /admin/login/*, taking effect with no restart (see onAccountsChanged).
|
|
1064
1145
|
const adminEnabled = process.env.DARIO_ADMIN === '1';
|
|
1065
1146
|
const accountsList = await loadAllAccounts();
|
|
1066
|
-
const
|
|
1147
|
+
const poolStrategy = resolvePoolStrategy(opts.poolStrategy);
|
|
1148
|
+
const pool = new AccountPool(poolStrategy);
|
|
1149
|
+
if (poolStrategy !== 'headroom') {
|
|
1150
|
+
console.log(` Pool strategy: ${poolStrategy} (new conversations fill the alphabetically-first seat, spill at the 2% floor)`);
|
|
1151
|
+
}
|
|
1067
1152
|
// Per-model rate-limit bucket families seen during this proxy run. First-
|
|
1068
1153
|
// sight is logged once when verbose so a new Anthropic bucket (e.g. an
|
|
1069
1154
|
// eventual `7d_opus`) doesn't slip past unnoticed. Pure observability —
|
|
@@ -1125,6 +1210,33 @@ export async function startProxy(opts = {}) {
|
|
|
1125
1210
|
accountUuid: acc.accountUuid,
|
|
1126
1211
|
});
|
|
1127
1212
|
}
|
|
1213
|
+
// Startup self-heal (dario#790): eagerly refresh any account whose access
|
|
1214
|
+
// token is already expired or within the 45-min refresh window BEFORE the
|
|
1215
|
+
// proxy starts serving. On a container recreate after >8h uptime the
|
|
1216
|
+
// on-disk token is stale; without this the account sits 'expired' in the
|
|
1217
|
+
// pool and every request 401s until the first background tick (up to
|
|
1218
|
+
// 15 min later). A single refresh recovers cleanly as long as the refresh
|
|
1219
|
+
// token is still live — and durably persists the rotated token to disk, so
|
|
1220
|
+
// the *next* recreate loads a fresh credential family too. A dead refresh
|
|
1221
|
+
// token (invalid_grant) just logs and leaves the account expired; the auth
|
|
1222
|
+
// gate below then surfaces it. Skipped in --no-claude-auth mode.
|
|
1223
|
+
if (!opts.noClaudeAuth) {
|
|
1224
|
+
await Promise.all(pool.all().map(async (acc) => {
|
|
1225
|
+
if (acc.expiresAt >= Date.now() + 45 * 60 * 1000)
|
|
1226
|
+
return;
|
|
1227
|
+
try {
|
|
1228
|
+
const saved = await loadAccount(acc.alias);
|
|
1229
|
+
if (!saved)
|
|
1230
|
+
return;
|
|
1231
|
+
const refreshed = await refreshAccountToken(saved);
|
|
1232
|
+
pool.updateTokens(acc.alias, refreshed.accessToken, refreshed.refreshToken, refreshed.expiresAt);
|
|
1233
|
+
console.error(`[dario] Startup refresh recovered account ${acc.alias} (was expired/expiring).`);
|
|
1234
|
+
}
|
|
1235
|
+
catch (err) {
|
|
1236
|
+
console.error(`[dario] Startup refresh failed for ${acc.alias}: ${err instanceof Error ? err.message : err}. Account left as-is; auth gate will surface it.`);
|
|
1237
|
+
}
|
|
1238
|
+
}));
|
|
1239
|
+
}
|
|
1128
1240
|
}
|
|
1129
1241
|
// Background refresh — keep every account's token fresh without blocking requests
|
|
1130
1242
|
const refreshInterval = setInterval(async () => {
|
|
@@ -1803,7 +1915,12 @@ export async function startProxy(opts = {}) {
|
|
|
1803
1915
|
// throws). [1m] variants come from the shared long-context rule, so
|
|
1804
1916
|
// every family advertises its 1M form the same way.
|
|
1805
1917
|
const catalog = await getModelCatalog(catalogDeps);
|
|
1806
|
-
|
|
1918
|
+
// User aliases are advertised after the real ids so pickers offer
|
|
1919
|
+
// them; already-advertised names aren't duplicated (an alias that
|
|
1920
|
+
// shadows a real id still applies at request time).
|
|
1921
|
+
const advertised = withLongContextVariants(catalog.bases);
|
|
1922
|
+
const aliasNames = Object.keys(modelAliases).filter((n) => !advertised.includes(n));
|
|
1923
|
+
const body = JSON.stringify(buildOpenAIModelsList(advertised.concat(aliasNames)));
|
|
1807
1924
|
res.writeHead(200, { ...JSON_HEADERS, 'Access-Control-Allow-Origin': corsOrigin });
|
|
1808
1925
|
res.end(body);
|
|
1809
1926
|
return;
|
|
@@ -1914,26 +2031,36 @@ export async function startProxy(opts = {}) {
|
|
|
1914
2031
|
// pool of one, so every OAuth request selects from the pool.
|
|
1915
2032
|
poolAccount = pool.select();
|
|
1916
2033
|
if (!poolAccount) {
|
|
1917
|
-
//
|
|
1918
|
-
//
|
|
1919
|
-
//
|
|
1920
|
-
//
|
|
1921
|
-
//
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
2034
|
+
// Pool-exhausted fallback: when armed, the pool HAS accounts (all
|
|
2035
|
+
// drained / cooling), and the client speaks OpenAI shape, defer —
|
|
2036
|
+
// the fallback dispatch below the body read re-points the request
|
|
2037
|
+
// at the openai-compat backend. An EMPTY pool still 503s: that's
|
|
2038
|
+
// a setup error the operator needs to see, not traffic to quietly
|
|
2039
|
+
// re-bill somewhere else.
|
|
2040
|
+
const fallbackViable = poolFallbackModel !== null && openaiBackend !== null
|
|
2041
|
+
&& isOpenAI && pool.size > 0;
|
|
2042
|
+
if (!fallbackViable) {
|
|
2043
|
+
// Two distinct empty-selection cases (#599): the pool has no accounts
|
|
2044
|
+
// at all (headless admin bootstrap — nothing added yet), vs. it has
|
|
2045
|
+
// accounts but all are rate-limited / in auth cool-down. Give each a
|
|
2046
|
+
// truthful, actionable message so a headless operator isn't told
|
|
2047
|
+
// "rate-limited" when they simply haven't added an account.
|
|
2048
|
+
res.writeHead(503, JSON_HEADERS);
|
|
2049
|
+
res.end(JSON.stringify(pool.size === 0
|
|
2050
|
+
? {
|
|
2051
|
+
error: 'No account configured',
|
|
2052
|
+
message: adminEnabled
|
|
2053
|
+
? 'dario is running in admin mode with no account yet. Add one via POST /admin/login/start, then retry.'
|
|
2054
|
+
: 'No accounts available. Run `dario login`, or add accounts with `dario accounts add`.',
|
|
2055
|
+
}
|
|
2056
|
+
: {
|
|
2057
|
+
error: 'No accounts available in pool',
|
|
2058
|
+
message: 'all accounts are rate-limited or in auth cool-down; retry shortly',
|
|
2059
|
+
}));
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
1935
2062
|
}
|
|
1936
|
-
accessToken = poolAccount
|
|
2063
|
+
accessToken = poolAccount?.accessToken ?? '';
|
|
1937
2064
|
}
|
|
1938
2065
|
// Client-side session key (constant per request) for the rotation registry
|
|
1939
2066
|
// — consulted at body-build, at the outbound header, and on each mid-request
|
|
@@ -1987,6 +2114,19 @@ export async function startProxy(opts = {}) {
|
|
|
1987
2114
|
try {
|
|
1988
2115
|
const parsed = JSON.parse(body.toString());
|
|
1989
2116
|
parsedBody = parsed;
|
|
2117
|
+
// User-defined aliases first — before provider-prefix parsing, so
|
|
2118
|
+
// an alias target carrying a prefix (`my-fast` → `openai:gpt-4o`)
|
|
2119
|
+
// retargets the backend through the existing machinery below.
|
|
2120
|
+
{
|
|
2121
|
+
const clientModel = parsed.model ?? '';
|
|
2122
|
+
const aliasTarget = applyModelAlias(clientModel, modelAliases);
|
|
2123
|
+
if (aliasTarget !== null) {
|
|
2124
|
+
parsed.model = aliasTarget;
|
|
2125
|
+
body = Buffer.from(JSON.stringify(parsed));
|
|
2126
|
+
if (verbose)
|
|
2127
|
+
console.log(`[dario] model alias: ${clientModel} → ${aliasTarget}`);
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
1990
2130
|
const rawModel = parsed.model ?? '';
|
|
1991
2131
|
const prefix = parseProviderPrefix(rawModel);
|
|
1992
2132
|
if (prefix) {
|
|
@@ -2042,11 +2182,26 @@ export async function startProxy(opts = {}) {
|
|
|
2042
2182
|
// through to the backend instead of running it through the Claude
|
|
2043
2183
|
// template path. Requests on /v1/messages or with Claude-family models
|
|
2044
2184
|
// fall through to existing behavior.
|
|
2045
|
-
|
|
2185
|
+
//
|
|
2186
|
+
// The decision itself lives in provider-adapter.ts (`route`): `route(...)
|
|
2187
|
+
// .provider === 'openai'` is exactly the prior inline condition
|
|
2188
|
+
// (`openaiBackend && isOpenAI && forcedProvider !== 'claude' &&
|
|
2189
|
+
// (forcedProvider === 'openai' || isOpenAIModel(model))`), consolidated so
|
|
2190
|
+
// the routing rule is testable and lives in one place. `openaiBackend`
|
|
2191
|
+
// stays in the guard for TS narrowing (route already implies it non-null).
|
|
2192
|
+
if (body.length > 0) {
|
|
2046
2193
|
try {
|
|
2047
2194
|
const peek = JSON.parse(body.toString());
|
|
2048
2195
|
const rawModel = (peek.model || '').toString();
|
|
2049
|
-
|
|
2196
|
+
const decision = routeProvider({
|
|
2197
|
+
isOpenAIPath: isOpenAI,
|
|
2198
|
+
model: rawModel,
|
|
2199
|
+
forcedProvider,
|
|
2200
|
+
hasOpenAIBackend: openaiBackend !== null,
|
|
2201
|
+
poolFallbackModel,
|
|
2202
|
+
poolSize: pool.size,
|
|
2203
|
+
});
|
|
2204
|
+
if (rawModel && openaiBackend && decision.provider === 'openai') {
|
|
2050
2205
|
if (verbose) {
|
|
2051
2206
|
console.log(`[dario] #${requestCount} ${req.method} ${urlPath} (model: ${rawModel}) → openai backend`);
|
|
2052
2207
|
}
|
|
@@ -2057,6 +2212,28 @@ export async function startProxy(opts = {}) {
|
|
|
2057
2212
|
}
|
|
2058
2213
|
catch { /* not JSON — fall through to existing path */ }
|
|
2059
2214
|
}
|
|
2215
|
+
// Pool-exhausted fallback dispatch. In OAuth mode poolAccount can only
|
|
2216
|
+
// be null here when the selection above deferred to this path (armed
|
|
2217
|
+
// fallback + drained pool + OpenAI-shape request): swap the model and
|
|
2218
|
+
// forward the client's own body to the openai-compat backend. The
|
|
2219
|
+
// response carries `x-dario-pool-fallback` — a substituted model must
|
|
2220
|
+
// never be silent. GPT-bound requests never reach here (the routing
|
|
2221
|
+
// block above already forwarded them; they don't need the pool).
|
|
2222
|
+
if (!upstreamApiKey && !poolAccount && poolFallbackModel && openaiBackend) {
|
|
2223
|
+
const fallbackBody = buildPoolFallbackBody(body, poolFallbackModel);
|
|
2224
|
+
if (!fallbackBody) {
|
|
2225
|
+
res.writeHead(503, JSON_HEADERS);
|
|
2226
|
+
res.end(JSON.stringify({
|
|
2227
|
+
error: 'No accounts available in pool',
|
|
2228
|
+
message: 'all accounts are rate-limited or in auth cool-down; retry shortly',
|
|
2229
|
+
}));
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2232
|
+
console.log(`[dario] #${requestCount} pool exhausted — /v1/chat/completions → ${openaiBackend.name} as ${poolFallbackModel}`);
|
|
2233
|
+
requestCount++;
|
|
2234
|
+
await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': poolFallbackModel }, UPSTREAM_TIMEOUT_MS, verbose);
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2060
2237
|
// Parse body once, apply OpenAI translation, model override, and sanitization
|
|
2061
2238
|
let finalBody = body.length > 0 ? body : undefined;
|
|
2062
2239
|
let ccToolMap = null;
|
|
@@ -2927,6 +3104,22 @@ export async function startProxy(opts = {}) {
|
|
|
2927
3104
|
continue dispatchLoop;
|
|
2928
3105
|
}
|
|
2929
3106
|
}
|
|
3107
|
+
// Pool-exhausted fallback: no peer left to fail over to. For an
|
|
3108
|
+
// OpenAI-shape request with the fallback armed, re-point the
|
|
3109
|
+
// client's own body at the openai-compat backend instead of
|
|
3110
|
+
// surfacing the 429. `body` still holds the client's OpenAI-shape
|
|
3111
|
+
// bytes — the Anthropic translation went into finalBody, never
|
|
3112
|
+
// back into body. Marked via x-dario-pool-fallback, same as the
|
|
3113
|
+
// selection-time path.
|
|
3114
|
+
if (isOpenAI && poolFallbackModel && openaiBackend) {
|
|
3115
|
+
const fallbackBody = buildPoolFallbackBody(body, poolFallbackModel);
|
|
3116
|
+
if (fallbackBody) {
|
|
3117
|
+
console.log(`[dario] #${requestCount} pool exhausted mid-flight (429, no peer) — /v1/chat/completions → ${openaiBackend.name} as ${poolFallbackModel}`);
|
|
3118
|
+
requestCount++;
|
|
3119
|
+
await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': poolFallbackModel }, UPSTREAM_TIMEOUT_MS, verbose);
|
|
3120
|
+
return;
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
2930
3123
|
const errBody = await upstream.text().catch(() => '');
|
|
2931
3124
|
const enriched = enrich429(errBody, upstream.headers);
|
|
2932
3125
|
const responseHeaders = {
|
|
@@ -3536,6 +3729,38 @@ export async function startProxy(opts = {}) {
|
|
|
3536
3729
|
// account periodic refresh that lived here is gone with the single-account
|
|
3537
3730
|
// path — refreshing credentials.json alongside the pool would double-refresh
|
|
3538
3731
|
// a shared lineage and trip reuse-detection (see the presence-loop note).
|
|
3732
|
+
// Flush the freshest in-memory pool tokens to disk on shutdown (dario#790,
|
|
3733
|
+
// belt-and-braces alongside persist-on-refresh). If a background refresh
|
|
3734
|
+
// rotated a token seconds before SIGTERM, this guarantees the rotated token
|
|
3735
|
+
// is on disk before the process exits — so the container recreate that
|
|
3736
|
+
// usually follows a SIGTERM (autodeploy) loads a live credential family
|
|
3737
|
+
// instead of a rotated-away one. Only writes when the in-memory token is
|
|
3738
|
+
// newer than what's on disk (freshest-wins), preserving the on-disk scopes /
|
|
3739
|
+
// identity; a stale in-memory copy never clobbers a fresher disk write.
|
|
3740
|
+
let flushingTokens = false;
|
|
3741
|
+
const flushPoolTokens = async () => {
|
|
3742
|
+
if (flushingTokens || opts.noClaudeAuth)
|
|
3743
|
+
return;
|
|
3744
|
+
flushingTokens = true;
|
|
3745
|
+
await Promise.all(pool.all().map(async (acc) => {
|
|
3746
|
+
try {
|
|
3747
|
+
const disk = await loadAccount(acc.alias);
|
|
3748
|
+
// Nothing on disk to merge scopes/identity from, or disk is already
|
|
3749
|
+
// at least as fresh — skip (avoids clobbering a concurrent writer).
|
|
3750
|
+
if (!disk)
|
|
3751
|
+
return;
|
|
3752
|
+
if (disk.expiresAt >= acc.expiresAt)
|
|
3753
|
+
return;
|
|
3754
|
+
await saveAccount({
|
|
3755
|
+
...disk,
|
|
3756
|
+
accessToken: acc.accessToken,
|
|
3757
|
+
refreshToken: acc.refreshToken,
|
|
3758
|
+
expiresAt: acc.expiresAt,
|
|
3759
|
+
});
|
|
3760
|
+
}
|
|
3761
|
+
catch { /* best-effort flush — never block shutdown on it */ }
|
|
3762
|
+
}));
|
|
3763
|
+
};
|
|
3539
3764
|
// Graceful shutdown
|
|
3540
3765
|
const shutdown = () => {
|
|
3541
3766
|
console.log('\n[dario] Shutting down...');
|
|
@@ -3543,8 +3768,13 @@ export async function startProxy(opts = {}) {
|
|
|
3543
3768
|
clearInterval(refreshInterval);
|
|
3544
3769
|
if (logFileStream)
|
|
3545
3770
|
logFileStream.end();
|
|
3546
|
-
|
|
3547
|
-
//
|
|
3771
|
+
// Flush tokens first (best-effort, bounded), then close the server. The
|
|
3772
|
+
// flush is fire-and-forget under the same 5s force-exit guard below so a
|
|
3773
|
+
// hung fsync can't wedge shutdown.
|
|
3774
|
+
void flushPoolTokens().finally(() => {
|
|
3775
|
+
server.close(() => process.exit(0));
|
|
3776
|
+
});
|
|
3777
|
+
// Force exit after 5s if connections (or the flush) don't complete.
|
|
3548
3778
|
setTimeout(() => process.exit(0), 5000).unref();
|
|
3549
3779
|
};
|
|
3550
3780
|
process.on('SIGINT', shutdown);
|
package/dist/tui/tabs/config.js
CHANGED
|
@@ -38,6 +38,7 @@ const FIELDS = [
|
|
|
38
38
|
{ path: 'thinkTime.maxMs', label: 'Think-time cap (ms)', type: 'number', hint: 'upper bound for the whole formula' },
|
|
39
39
|
{ path: 'sessionStart.minMs', label: 'Session-start min', type: 'number', hint: 'first-request delay floor' },
|
|
40
40
|
{ path: 'sessionStart.jitterMs', label: 'Session-start jitter', type: 'number' },
|
|
41
|
+
{ path: 'pool.strategy', label: 'Pool strategy', type: 'string', hint: '"headroom" (default) or "fill-first"' },
|
|
41
42
|
// ── Overage-guard (v4.1, dario#288) ─────────────────────────
|
|
42
43
|
{ path: 'overageGuard.enabled', label: 'Overage-guard', type: 'bool', hint: 'halt proxy on any representative-claim=overage' },
|
|
43
44
|
{ path: 'overageGuard.behavior', label: 'Overage behavior', type: 'string', hint: '"halt" (default) or "warn"' },
|
|
@@ -255,6 +256,7 @@ function commitEdit(state) {
|
|
|
255
256
|
*/
|
|
256
257
|
const STRING_ENUMS = {
|
|
257
258
|
'overageGuard.behavior': ['halt', 'warn'],
|
|
259
|
+
'pool.strategy': ['headroom', 'fill-first'],
|
|
258
260
|
};
|
|
259
261
|
function doSave(state) {
|
|
260
262
|
try {
|