@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.
@@ -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 pool = new AccountPool();
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
- const body = JSON.stringify(buildOpenAIModelsList(withLongContextVariants(catalog.bases)));
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
- // Two distinct empty-selection cases (#599): the pool has no accounts
1918
- // at all (headless admin bootstrap nothing added yet), vs. it has
1919
- // accounts but all are rate-limited / in auth cool-down. Give each a
1920
- // truthful, actionable message so a headless operator isn't told
1921
- // "rate-limited" when they simply haven't added an account.
1922
- res.writeHead(503, JSON_HEADERS);
1923
- res.end(JSON.stringify(pool.size === 0
1924
- ? {
1925
- error: 'No account configured',
1926
- message: adminEnabled
1927
- ? 'dario is running in admin mode with no account yet. Add one via POST /admin/login/start, then retry.'
1928
- : 'No accounts available. Run `dario login`, or add accounts with `dario accounts add`.',
1929
- }
1930
- : {
1931
- error: 'No accounts available in pool',
1932
- message: 'all accounts are rate-limited or in auth cool-down; retry shortly',
1933
- }));
1934
- return;
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.accessToken;
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
- if (openaiBackend && isOpenAI && forcedProvider !== 'claude' && body.length > 0) {
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
- if (rawModel && (forcedProvider === 'openai' || isOpenAIModel(rawModel))) {
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
- server.close(() => process.exit(0));
3547
- // Force exit after 5s if connections don't close
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);
@@ -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 {