@askalf/dario 5.0.1 → 5.1.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/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p>
8
8
  <a href="https://www.npmjs.com/package/@askalf/dario"><img src="https://img.shields.io/npm/v/@askalf/dario?color=6f42c1&label=npm&logo=npm" alt="npm version"></a>
9
- <a href="https://github.com/askalf/dario/releases"><img src="https://img.shields.io/badge/release-v5.0-6f42c1?logo=github" alt="v5.0"></a>
9
+ <a href="https://github.com/askalf/dario/releases"><img src="https://img.shields.io/github/v/release/askalf/dario?color=6f42c1&label=release&logo=github" alt="Latest release"></a>
10
10
  <a href="https://github.com/askalf/dario/actions/workflows/ci.yml"><img src="https://github.com/askalf/dario/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
11
11
  <a href="https://github.com/askalf/dario/actions/workflows/codeql.yml"><img src="https://github.com/askalf/dario/actions/workflows/codeql.yml/badge.svg" alt="CodeQL"></a>
12
12
  <a href="https://scorecard.dev/viewer/?uri=github.com/askalf/dario"><img src="https://img.shields.io/ossf-scorecard/github.com/askalf/dario?label=OpenSSF%20Scorecard&color=6f42c1" alt="OpenSSF Scorecard"></a>
package/dist/cli.js CHANGED
@@ -348,8 +348,16 @@ async function proxy() {
348
348
  // proxy presents to Anthropic is Bun's BoringSSL ClientHello, not
349
349
  // Node's OpenSSL one. v3.23 (direction #3).
350
350
  const strictTls = args.includes('--strict-tls');
351
+ // --no-claude-auth: don't load/refresh the Claude OAuth pool (OpenAI-only
352
+ // proxies). Stops dario rotating a shared refresh token out from under an
353
+ // interactive Claude Code on the same machine.
354
+ const noClaudeAuth = args.includes('--no-claude-auth');
351
355
  const modelArg = args.find(a => a.startsWith('--model='));
352
356
  const model = modelArg ? modelArg.split('=')[1] : undefined;
357
+ // --fast-model=MODEL: route Haiku-tier (CC sub-agent) requests to this
358
+ // model instead of the forced --model, so sub-agents stay cheap.
359
+ const fastModelArg = args.find(a => a.startsWith('--fast-model='));
360
+ const fastModel = fastModelArg ? fastModelArg.split('=')[1] : undefined;
353
361
  // --pace-min=MS / --pace-jitter=MS (v3.24, direction #6 — behavioral
354
362
  // smoothing). Inter-request gap floor + optional uniform-random jitter.
355
363
  // v4: ~/.dario/config.json's `pacing.{minMs,jitterMs}` is the fallback
@@ -569,7 +577,7 @@ async function proxy() {
569
577
  console.error(`[dario] Override (not recommended): pass --unsafe-no-auth if you have out-of-band network controls and accept the risk.`);
570
578
  process.exit(1);
571
579
  }
572
- await startProxy({ port, host, verbose, verboseBodies, model, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, effort, maxTokens, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat });
580
+ await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, effort, maxTokens, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat });
573
581
  }
574
582
  /**
575
583
  * Parse `--system-prompt=<verbatim|partial|aggressive|filepath>` (or the
@@ -1198,6 +1206,16 @@ async function help() {
1198
1206
  Provider prefix: openai:gpt-4o, groq:llama-3.3-70b,
1199
1207
  claude:opus, local:qwen-coder (forces backend)
1200
1208
  Default: passthrough (client decides)
1209
+ --fast-model=MODEL Route Haiku-tier sub-agent requests to MODEL
1210
+ instead of --model, so Claude Code's cheap
1211
+ sub-agents aren't upgraded to the forced model.
1212
+ Same MODEL forms as --model. No effect unless set.
1213
+ --no-claude-auth Don't load or refresh the Claude OAuth token —
1214
+ for OpenAI-only proxies (e.g. --model=openai:...).
1215
+ Prevents dario rotating a shared refresh token out
1216
+ from under an interactive Claude Code on the same
1217
+ machine. Claude-bound requests then return a clean
1218
+ unauthenticated error.
1201
1219
  --passthrough, --thin Thin proxy — OAuth swap only, no injection
1202
1220
  --preserve-tools Forward client tool schemas unchanged
1203
1221
  Loses subscription routing; use for custom agents
package/dist/oauth.d.ts CHANGED
@@ -137,6 +137,15 @@ export declare function readLineFromStdin(prompt: string): Promise<string>;
137
137
  * Retries with exponential backoff on transient failures.
138
138
  * Uses a mutex to prevent concurrent refresh races.
139
139
  */
140
+ /**
141
+ * A refresh failure that retrying cannot recover: the refresh token is
142
+ * invalid, revoked, or rotated out (e.g. a second dario on the same account
143
+ * consumed the single-use token). Anthropic signals this as HTTP 401/403, or
144
+ * as a 400 with an `invalid_grant` body ("Refresh token not found or
145
+ * invalid"). Terminal -> fail fast with a re-login prompt instead of burning
146
+ * doomed retries and masking as healthy.
147
+ */
148
+ export declare function isTerminalRefreshFailure(status: number, body: string): boolean;
140
149
  export declare function refreshTokens(): Promise<OAuthTokens>;
141
150
  /**
142
151
  * Get a valid access token, refreshing if needed.
package/dist/oauth.js CHANGED
@@ -39,6 +39,12 @@ const REFRESH_COOLDOWN_MS = 60 * 1000;
39
39
  let consecutiveRefreshFailures = 0;
40
40
  let lastRefreshError;
41
41
  const REFRESH_BROKEN_THRESHOLD = 3;
42
+ // Set the moment a refresh fails TERMINALLY (invalid_grant / 401 / 403 — a
43
+ // dead, revoked, or rotated-out refresh token). Unlike the count-based
44
+ // threshold, one terminal failure is conclusive, so surface `broken`
45
+ // immediately instead of masking as healthy for THRESHOLD x cooldown. Cleared
46
+ // whenever new credentials are saved (a successful refresh or `dario login`).
47
+ let refreshTokenDead = false;
42
48
  // In-memory credential cache — avoids disk reads on every request
43
49
  let credentialsCache = null;
44
50
  let credentialsCacheTime = 0;
@@ -401,6 +407,9 @@ export function pickFreshestCredentials(candidates) {
401
407
  return best;
402
408
  }
403
409
  async function saveCredentials(creds) {
410
+ // New credentials (a successful refresh or a fresh `dario login`) mean the
411
+ // token is live again — clear the terminal-dead flag.
412
+ refreshTokenDead = false;
404
413
  const path = getDarioCredentialsPath();
405
414
  await mkdir(dirname(path), { recursive: true });
406
415
  // Write atomically: write to temp file, then rename
@@ -688,6 +697,17 @@ export async function readLineFromStdin(prompt) {
688
697
  * Retries with exponential backoff on transient failures.
689
698
  * Uses a mutex to prevent concurrent refresh races.
690
699
  */
700
+ /**
701
+ * A refresh failure that retrying cannot recover: the refresh token is
702
+ * invalid, revoked, or rotated out (e.g. a second dario on the same account
703
+ * consumed the single-use token). Anthropic signals this as HTTP 401/403, or
704
+ * as a 400 with an `invalid_grant` body ("Refresh token not found or
705
+ * invalid"). Terminal -> fail fast with a re-login prompt instead of burning
706
+ * doomed retries and masking as healthy.
707
+ */
708
+ export function isTerminalRefreshFailure(status, body) {
709
+ return status === 401 || status === 403 || /invalid_grant/i.test(body);
710
+ }
691
711
  export async function refreshTokens() {
692
712
  // Prevent concurrent refreshes — if one is already in progress, wait for it
693
713
  if (refreshInProgress)
@@ -723,8 +743,13 @@ async function doRefreshTokens() {
723
743
  if (!res.ok) {
724
744
  const errBody = await res.text().catch(() => '');
725
745
  console.error(`[dario] Refresh attempt ${attempt + 1}/3 failed: HTTP ${res.status} — ${redactSecrets(errBody.slice(0, 200))}`);
726
- if (res.status === 401 || res.status === 403) {
727
- throw new Error(`Refresh token rejected (${res.status}). Run \`dario login\` to re-authenticate.`);
746
+ // Terminal failures (invalid_grant / 401 / 403) can't be retried away —
747
+ // the refresh token is dead. Flag it so /status, /health, and doctor
748
+ // report `broken` immediately (not after THRESHOLD x cooldown), and fail
749
+ // fast with an actionable message instead of a vague "3 attempts" throw.
750
+ if (isTerminalRefreshFailure(res.status, errBody)) {
751
+ refreshTokenDead = true;
752
+ throw new Error(`Refresh token invalid or revoked (HTTP ${res.status}) — run \`dario login\` to re-authenticate.`);
728
753
  }
729
754
  continue;
730
755
  }
@@ -795,7 +820,7 @@ export async function getStatus() {
795
820
  }
796
821
  const { expiresAt } = creds.claudeAiOauth;
797
822
  const now = Date.now();
798
- const broken = consecutiveRefreshFailures >= REFRESH_BROKEN_THRESHOLD;
823
+ const broken = refreshTokenDead || consecutiveRefreshFailures >= REFRESH_BROKEN_THRESHOLD;
799
824
  if (expiresAt < now) {
800
825
  // Expired but has refresh token — can be refreshed (unless refresh itself is dead)
801
826
  const canRefresh = !!creds.claudeAiOauth.refreshToken && !broken;
package/dist/proxy.d.ts CHANGED
@@ -37,6 +37,21 @@ 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
+ * Pick the per-request model override under a forced `--model`.
42
+ *
43
+ * A forced `--model` normally rewrites the model on *every* request. But
44
+ * Claude Code dispatches its throwaway sub-agent (Explore/Task) turns on the
45
+ * cheap Haiku tier, so forcing a frontier model silently upgrades those
46
+ * sub-agents too — quietly multiplying cost. When `--fast-model` is set, a
47
+ * Haiku-tier inbound request routes there instead, keeping sub-agents cheap
48
+ * while the main conversation stays on `--model`.
49
+ *
50
+ * No-op unless `fastModelOverride` is set: with it null, this returns
51
+ * `modelOverride` for every request (the pre-existing behavior), so the
52
+ * change is inert until the operator opts in.
53
+ */
54
+ export declare function selectModelOverride(incomingModel: string, modelOverride: string | null, fastModelOverride: string | null): string | null;
40
55
  export declare function parseProviderPrefix(model: string): {
41
56
  provider: 'openai' | 'claude';
42
57
  model: string;
@@ -209,12 +224,21 @@ export declare const OPENAI_MODELS_LIST: {
209
224
  owned_by: string;
210
225
  }>;
211
226
  };
227
+ /**
228
+ * Whether dario must have a Claude login to start. False (an empty pool is
229
+ * expected, not a fatal "run dario login") for the modes that serve requests
230
+ * without the Claude OAuth pool: admin-bootstrap, upstream-api-key, and
231
+ * --no-claude-auth. Pure so the startup gate is unit-testable.
232
+ */
233
+ export declare function requiresClaudeLogin(poolSize: number, adminEnabled: boolean, hasUpstreamApiKey: boolean, noClaudeAuth: boolean): boolean;
212
234
  interface ProxyOptions {
213
235
  port?: number;
214
236
  host?: string;
215
237
  verbose?: boolean;
216
238
  verboseBodies?: boolean;
217
239
  model?: string;
240
+ fastModel?: string;
241
+ noClaudeAuth?: boolean;
218
242
  passthrough?: boolean;
219
243
  preserveTools?: boolean;
220
244
  hybridTools?: boolean;
package/dist/proxy.js CHANGED
@@ -235,6 +235,25 @@ const MODEL_ALIASES = {
235
235
  export function resolveClaudeAlias(model) {
236
236
  return resolveAliasAgainst(model, getCachedBases()) ?? MODEL_ALIASES[model] ?? model;
237
237
  }
238
+ /**
239
+ * Pick the per-request model override under a forced `--model`.
240
+ *
241
+ * A forced `--model` normally rewrites the model on *every* request. But
242
+ * Claude Code dispatches its throwaway sub-agent (Explore/Task) turns on the
243
+ * cheap Haiku tier, so forcing a frontier model silently upgrades those
244
+ * sub-agents too — quietly multiplying cost. When `--fast-model` is set, a
245
+ * Haiku-tier inbound request routes there instead, keeping sub-agents cheap
246
+ * while the main conversation stays on `--model`.
247
+ *
248
+ * No-op unless `fastModelOverride` is set: with it null, this returns
249
+ * `modelOverride` for every request (the pre-existing behavior), so the
250
+ * change is inert until the operator opts in.
251
+ */
252
+ export function selectModelOverride(incomingModel, modelOverride, fastModelOverride) {
253
+ if (fastModelOverride && /haiku/i.test(incomingModel))
254
+ return fastModelOverride;
255
+ return modelOverride;
256
+ }
238
257
  // Provider prefix in the `model` field — `<provider>:<model>`. Forces
239
258
  // routing regardless of model-name regex. Only recognized prefixes are
240
259
  // parsed, so ollama-style `llama3:8b` (without a recognized prefix)
@@ -683,6 +702,15 @@ export function createOpenAIStreamTranslator() {
683
702
  // autodetected catalog (model-catalog.ts); `[1m]` variants are GENERATED by
684
703
  // the one shared long-context rule, never hand-listed per model.
685
704
  export const OPENAI_MODELS_LIST = buildOpenAIModelsList(withLongContextVariants(BAKED_BASE_MODELS));
705
+ /**
706
+ * Whether dario must have a Claude login to start. False (an empty pool is
707
+ * expected, not a fatal "run dario login") for the modes that serve requests
708
+ * without the Claude OAuth pool: admin-bootstrap, upstream-api-key, and
709
+ * --no-claude-auth. Pure so the startup gate is unit-testable.
710
+ */
711
+ export function requiresClaudeLogin(poolSize, adminEnabled, hasUpstreamApiKey, noClaudeAuth) {
712
+ return poolSize === 0 && !adminEnabled && !hasUpstreamApiKey && !noClaudeAuth;
713
+ }
686
714
  /**
687
715
  * Append a JSON-ND line to the proxy log file. No-op when stream is
688
716
  * null (logFile not configured). Errors are swallowed — log writes
@@ -1060,17 +1088,29 @@ export async function startProxy(opts = {}) {
1060
1088
  console.error(`[dario] overage-guard resumed (${info.reason}). Normal request handling restored.`);
1061
1089
  });
1062
1090
  let status;
1063
- for (const acc of accountsList) {
1064
- pool.add(acc.alias, {
1065
- accessToken: acc.accessToken,
1066
- refreshToken: acc.refreshToken,
1067
- expiresAt: acc.expiresAt,
1068
- deviceId: acc.deviceId,
1069
- accountUuid: acc.accountUuid,
1070
- });
1091
+ // --no-claude-auth: don't populate the Claude OAuth pool. An empty pool makes
1092
+ // the background refresh a no-op, so dario never touches (and never rotates)
1093
+ // the shared Claude refresh token — the fix for a local OpenAI-only proxy
1094
+ // logging out an interactive Claude Code on the same machine (dario#737 class,
1095
+ // locally). OpenAI-compat requests use their own backend key, unaffected.
1096
+ if (opts.noClaudeAuth) {
1097
+ console.error('[dario] --no-claude-auth: Claude OAuth pool NOT loaded — serving OpenAI-compatible backends only; the Claude refresh token is never touched. Claude-bound requests return an unauthenticated error.');
1098
+ }
1099
+ else {
1100
+ for (const acc of accountsList) {
1101
+ pool.add(acc.alias, {
1102
+ accessToken: acc.accessToken,
1103
+ refreshToken: acc.refreshToken,
1104
+ expiresAt: acc.expiresAt,
1105
+ deviceId: acc.deviceId,
1106
+ accountUuid: acc.accountUuid,
1107
+ });
1108
+ }
1071
1109
  }
1072
1110
  // Background refresh — keep every account's token fresh without blocking requests
1073
1111
  const refreshInterval = setInterval(async () => {
1112
+ if (opts.noClaudeAuth)
1113
+ return; // never touch the Claude token in OpenAI-only mode
1074
1114
  for (const acc of pool.all()) {
1075
1115
  if (acc.expiresAt < Date.now() + 45 * 60 * 1000) {
1076
1116
  try {
@@ -1098,7 +1138,7 @@ export async function startProxy(opts = {}) {
1098
1138
  // dead-but-refreshable token (a container restarted right after a normal
1099
1139
  // expiry, gap #1) is refreshed, then back-filled so the recovered login
1100
1140
  // becomes the pool-of-one it should be — rather than crash-looping on exit(1).
1101
- if (pool.size === 0 && !adminEnabled && !upstreamApiKey) {
1141
+ if (requiresClaudeLogin(pool.size, adminEnabled, !!upstreamApiKey, opts.noClaudeAuth ?? false)) {
1102
1142
  const single = await resolveSingleAccountStartupStatus();
1103
1143
  if (!single.authenticated) {
1104
1144
  console.error('[dario] Not authenticated. Run `dario login` first.');
@@ -1150,6 +1190,12 @@ export async function startProxy(opts = {}) {
1150
1190
  const cliModelRaw = modelPrefix ? modelPrefix.model : opts.model;
1151
1191
  const cliProviderOverride = modelPrefix ? modelPrefix.provider : null;
1152
1192
  const modelOverride = cliModelRaw ? resolveClaudeAlias(cliModelRaw) : null;
1193
+ // --fast-model: the model that Haiku-tier (Claude Code sub-agent) requests
1194
+ // route to instead of the forced `--model`, so sub-agents stay cheap. Parsed
1195
+ // like --model (alias + optional provider prefix). Null = disabled.
1196
+ const fastModelPrefix = opts.fastModel ? parseProviderPrefix(opts.fastModel) : null;
1197
+ const fastModelRaw = fastModelPrefix ? fastModelPrefix.model : opts.fastModel;
1198
+ const fastModelOverride = fastModelRaw ? resolveClaudeAlias(fastModelRaw) : null;
1153
1199
  const identity = loadClaudeIdentity();
1154
1200
  if (identity.deviceId) {
1155
1201
  console.log(' Device identity: detected');
@@ -2034,7 +2080,13 @@ export async function startProxy(opts = {}) {
2034
2080
  const parsed = parsedBody ?? JSON.parse(body.toString());
2035
2081
  // Strip orchestration tags from messages (Aider, Cursor, etc.)
2036
2082
  sanitizeMessages(parsed, opts.preserveOrchestrationTags);
2037
- const result = isOpenAI ? openaiToAnthropic(parsed, modelOverride) : (modelOverride ? { ...parsed, model: modelOverride } : parsed);
2083
+ // Tier-aware routing: under a forced --model, a Haiku-tier sub-agent
2084
+ // request routes to --fast-model (when set) instead of the forced
2085
+ // model, so CC's cheap sub-agents aren't silently upgraded. Inert
2086
+ // when --fast-model is unset (effectiveOverride === modelOverride).
2087
+ const incomingModel = typeof parsed.model === 'string' ? parsed.model : '';
2088
+ const effectiveOverride = selectModelOverride(incomingModel, modelOverride, fastModelOverride);
2089
+ const result = isOpenAI ? openaiToAnthropic(parsed, effectiveOverride) : (effectiveOverride ? { ...parsed, model: effectiveOverride } : parsed);
2038
2090
  const r = result;
2039
2091
  requestModel = (r.model || '').toLowerCase();
2040
2092
  // Suspended-model guard. Empty by default (Fable 5 returned globally
@@ -3364,7 +3416,10 @@ export async function startProxy(opts = {}) {
3364
3416
  const modeLine = passthrough
3365
3417
  ? 'Mode: passthrough (OAuth swap only, no injection)'
3366
3418
  : `OAuth: ${status.status} (expires in ${status.expiresIn})`;
3367
- const modelLine = modelOverride ? `Model: ${modelOverride} (all requests)` : 'Model: passthrough (client decides)';
3419
+ const fastNote = fastModelOverride ? `, ${fastModelOverride} (Haiku-tier sub-agents)` : '';
3420
+ const modelLine = modelOverride
3421
+ ? `Model: ${modelOverride} (all requests)${fastNote}`
3422
+ : `Model: passthrough (client decides)${fastNote}`;
3368
3423
  // Pool line surfaces the account state on every startup. The pool is the
3369
3424
  // one credential model now (v5.0): a plain `dario login` shows as a pool of
3370
3425
  // one. An empty pool is only reachable in admin-bootstrap / api-key mode.
@@ -3388,6 +3443,8 @@ export async function startProxy(opts = {}) {
3388
3443
  console.log('');
3389
3444
  console.log(` ${modeLine}`);
3390
3445
  console.log(` ${modelLine}`);
3446
+ if (opts.noClaudeAuth)
3447
+ console.log(' Claude auth: disabled (--no-claude-auth) — OpenAI-compatible backends only; Claude token untouched');
3391
3448
  console.log(` ${poolLine}`);
3392
3449
  if (!isLoopbackHost(host)) {
3393
3450
  console.log('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "5.0.1",
3
+ "version": "5.1.0",
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": {