@askalf/dario 6.19.2 → 6.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/keys.js CHANGED
@@ -37,6 +37,10 @@ export const KEY_PREFIX = 'dk_';
37
37
  export const KEY_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_\-.]{0,63}$/;
38
38
  export const KEYS_FLUSH_DELAY_MS = 3_000;
39
39
  const SECRET_BYTES = 24;
40
+ export const KEY_EFFORT_VALUES = ['low', 'medium', 'high', 'xhigh', 'max'];
41
+ export function isKeyEffort(v) {
42
+ return typeof v === 'string' && KEY_EFFORT_VALUES.includes(v);
43
+ }
40
44
  /** A conservative bytes-per-token for the reservation: prose is ~4, code and CJK are lower. */
41
45
  export const BUDGET_BYTES_PER_TOKEN = 3;
42
46
  /** Reserved output when the client sends no max_tokens / max_completion_tokens / max_output_tokens. */
@@ -166,6 +170,20 @@ export function setKeyBudget(file, name, budget) {
166
170
  delete k.budget;
167
171
  return k;
168
172
  }
173
+ /** Set or clear a key's default effort. Returns null when there is no such key; throws on a level outside KEY_EFFORT_VALUES. */
174
+ export function setKeyEffort(file, name, effort) {
175
+ const k = file.keys.find((x) => x.name === name);
176
+ if (!k)
177
+ return null;
178
+ if (effort === null || effort === undefined || effort === '') {
179
+ delete k.effort;
180
+ return k;
181
+ }
182
+ if (!isKeyEffort(effort))
183
+ throw new Error(`invalid effort "${effort}": one of ${KEY_EFFORT_VALUES.join(', ')}`);
184
+ k.effort = effort;
185
+ return k;
186
+ }
169
187
  export function keysPathFor(home = homedir()) {
170
188
  return join(home, '.dario', 'keys.json');
171
189
  }
@@ -239,6 +257,9 @@ export function parseKeysFile(text) {
239
257
  }
240
258
  catch { /* a malformed cap is dropped, never a reason to refuse the whole file */ }
241
259
  }
260
+ // A default effort outside the wire-valid set is dropped, like a malformed cap.
261
+ if (isKeyEffort(k.effort))
262
+ rec.effort = k.effort;
242
263
  seen.add(rec.name);
243
264
  keys.push(rec);
244
265
  }
@@ -294,6 +315,11 @@ export function createKey(file, name, opts = {}) {
294
315
  const budget = normalizeBudget(opts.budget);
295
316
  if (budget)
296
317
  record.budget = budget;
318
+ if (opts.effort !== undefined && opts.effort !== '') {
319
+ if (!isKeyEffort(opts.effort))
320
+ throw new Error(`invalid effort "${opts.effort}": one of ${KEY_EFFORT_VALUES.join(', ')}`);
321
+ record.effort = opts.effort;
322
+ }
297
323
  if (opts.expiresAt !== undefined) {
298
324
  if (!Number.isFinite(opts.expiresAt) || opts.expiresAt <= (opts.now ?? Date.now()))
299
325
  throw new Error('expiry must be in the future');
@@ -371,7 +397,7 @@ export function keyAllowsModel(k, model) {
371
397
  export function publicKey(k, now = Date.now()) {
372
398
  const status = k.disabled ? 'revoked' : k.expires && Date.parse(k.expires) <= now ? 'expired' : 'active';
373
399
  const budget = k.budget ? { usd_per_day: k.budget.usdPerDay ?? null, tokens_per_day: k.budget.tokensPerDay ?? null } : null;
374
- return { id: k.id, name: k.name, created: k.created, last_used: k.lastUsed ?? null, status, expires: k.expires ?? null, seat: k.seat ?? null, budget, models: k.models ?? [] };
400
+ return { id: k.id, name: k.name, created: k.created, last_used: k.lastUsed ?? null, status, expires: k.expires ?? null, seat: k.seat ?? null, budget, effort: k.effort ?? null, models: k.models ?? [] };
375
401
  }
376
402
  /** `--expires=30d` / `12h` / `2026-12-31` → epoch ms, or null when unparseable. */
377
403
  export function parseExpiry(value, now = Date.now()) {
package/dist/ledger.d.ts CHANGED
@@ -38,6 +38,12 @@ export interface LedgerCell {
38
38
  outputTokens: number;
39
39
  cacheReadTokens: number;
40
40
  cacheCreateTokens: number;
41
+ /**
42
+ * Thinking tokens INSIDE outputTokens, a share and never an addition (the
43
+ * model's reasoning is billed as output). Counted from the usage block the
44
+ * backend returns; rows written before the field existed read as 0.
45
+ */
46
+ thinkingTokens: number;
41
47
  }
42
48
  export type LedgerRow = Partial<Record<LedgerBucket, LedgerCell>>;
43
49
  export interface LedgerFile {
@@ -67,6 +73,9 @@ export interface LedgerConsumerSummary {
67
73
  outputTokens: number;
68
74
  cacheReadTokens: number;
69
75
  cacheCreateTokens: number;
76
+ /** Of outputTokens, how many were thinking; and what that thinking would have cost at the model's output rate (both buckets). */
77
+ thinkingTokens: number;
78
+ thinkingCost: number;
70
79
  recent: {
71
80
  today: number;
72
81
  last7d: number;
@@ -84,6 +93,8 @@ export interface LedgerModelSummary {
84
93
  outputTokens: number;
85
94
  cacheReadTokens: number;
86
95
  cacheCreateTokens: number;
96
+ /** Of the covered outputTokens, how many were thinking. */
97
+ thinkingTokens: number;
87
98
  /** API-equivalent cost of this model's covered traffic, USD. */
88
99
  apiEquivalentCost: number;
89
100
  /** What this model's metered traffic cost at list price, USD. */
@@ -106,13 +117,16 @@ export interface LedgerSummary {
106
117
  apiEquivalentCost: number;
107
118
  /** What metered traffic (API key, paid overage) actually cost at list price, USD. */
108
119
  meteredCost: number;
109
- /** Covered token totals. */
120
+ /** Covered token totals. `thinking` is the share of `output` that was reasoning. */
110
121
  tokens: {
111
122
  input: number;
112
123
  output: number;
113
124
  cacheRead: number;
114
125
  cacheCreate: number;
126
+ thinking: number;
115
127
  };
128
+ /** The part of apiEquivalentCost that paid for thinking: covered thinking tokens at each model's output rate, USD. */
129
+ thinkingCost: number;
116
130
  perProvider: Record<PricingProvider, {
117
131
  requests: number;
118
132
  apiEquivalentCost: number;
@@ -238,6 +252,8 @@ export declare function shortModelName(model: string): string;
238
252
  * to match the rest of that command's output.
239
253
  */
240
254
  export declare function formatLedgerSummary(s: LedgerSummary): string[];
255
+ /** Thinking as a whole percent of output; 0 when there was no output. */
256
+ export declare function thinkingPercent(thinking: number, output: number): number;
241
257
  /**
242
258
  * `dario usage --by-key`: the lifetime number per consumer, biggest first.
243
259
  * A consumer is a named key's name, an `x-dario-consumer` header, or the
package/dist/ledger.js CHANGED
@@ -52,7 +52,7 @@ export function resolveLedgerPath(port, env = process.env) {
52
52
  export function ledgerDisabledByEnv(env = process.env) {
53
53
  return ['0', 'false', 'no', 'off'].includes((env['DARIO_LEDGER'] ?? '').toLowerCase());
54
54
  }
55
- const emptyCell = () => ({ requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0 });
55
+ const emptyCell = () => ({ requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0 });
56
56
  export function emptyLedger(now = Date.now()) {
57
57
  const iso = new Date(now).toISOString();
58
58
  return { version: LEDGER_VERSION, since: iso, updated: iso, days: {} };
@@ -90,6 +90,17 @@ function isCell(v) {
90
90
  return ['requests', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheCreateTokens']
91
91
  .every((k) => typeof c[k] === 'number' && Number.isFinite(c[k]) && c[k] >= 0);
92
92
  }
93
+ /**
94
+ * A cell as read from disk, with the fields added after the file was written
95
+ * filled in: `thinkingTokens` is 0 on rows older than the field (and never
96
+ * above the output count it is a share of).
97
+ */
98
+ function cleanCell(c) {
99
+ const raw = c.thinkingTokens;
100
+ const thinking = typeof raw === 'number' && Number.isFinite(raw) && raw >= 0 ? Math.min(raw, c.outputTokens) : 0;
101
+ return { requests: c.requests, inputTokens: c.inputTokens, outputTokens: c.outputTokens,
102
+ cacheReadTokens: c.cacheReadTokens, cacheCreateTokens: c.cacheCreateTokens, thinkingTokens: thinking };
103
+ }
93
104
  /**
94
105
  * Parse a ledger file's text, keeping only well-formed rows. A file that is
95
106
  * not a ledger at all throws; the caller moves it aside and starts fresh.
@@ -109,9 +120,9 @@ export function parseLedger(text) {
109
120
  continue;
110
121
  const r = {};
111
122
  if (isCell(row.covered))
112
- r.covered = { ...row.covered };
123
+ r.covered = cleanCell(row.covered);
113
124
  if (isCell(row.metered))
114
- r.metered = { ...row.metered };
125
+ r.metered = cleanCell(row.metered);
115
126
  if (r.covered || r.metered)
116
127
  clean[model] = r;
117
128
  }
@@ -136,9 +147,9 @@ export function parseLedger(text) {
136
147
  continue;
137
148
  const r = {};
138
149
  if (isCell(row.covered))
139
- r.covered = { ...row.covered };
150
+ r.covered = cleanCell(row.covered);
140
151
  if (isCell(row.metered))
141
- r.metered = { ...row.metered };
152
+ r.metered = cleanCell(row.metered);
142
153
  if (r.covered || r.metered)
143
154
  clean[model] = r;
144
155
  }
@@ -160,6 +171,8 @@ function addCell(row, bucket, record) {
160
171
  cell.outputTokens += record.outputTokens;
161
172
  cell.cacheReadTokens += record.cacheReadTokens;
162
173
  cell.cacheCreateTokens += record.cacheCreateTokens;
174
+ const thinking = Number.isFinite(record.thinkingTokens) && record.thinkingTokens > 0 ? record.thinkingTokens : 0;
175
+ cell.thinkingTokens = (cell.thinkingTokens ?? 0) + Math.min(thinking, record.outputTokens);
163
176
  }
164
177
  /** Add one record's tokens to the file in place. Returns false when it was not counted. */
165
178
  export function addToLedger(file, record) {
@@ -203,13 +216,14 @@ export function summarizeLedgerConsumers(file, now = Date.now()) {
203
216
  for (const [day, byConsumer] of Object.entries(file.consumers ?? {})) {
204
217
  const at = dayMs(day);
205
218
  for (const [consumer, models] of Object.entries(byConsumer)) {
206
- const c = (out[consumer] ??= { requests: 0, apiEquivalentCost: 0, meteredCost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, recent: { today: 0, last7d: 0, last30d: 0 }, lastDay: day, models: [], _models: {} });
219
+ const c = (out[consumer] ??= { requests: 0, apiEquivalentCost: 0, meteredCost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0, thinkingCost: 0, recent: { today: 0, last7d: 0, last30d: 0 }, lastDay: day, models: [], _models: {} });
207
220
  if (day > c.lastDay)
208
221
  c.lastDay = day;
209
222
  for (const [model, row] of Object.entries(models)) {
210
223
  if (row.covered) {
211
224
  const cost = costOfTokens(model, at, row.covered);
212
225
  c.apiEquivalentCost += cost;
226
+ c.thinkingCost += costOfTokens(model, at, thinkingOnly(row.covered));
213
227
  c.requests += row.covered.requests;
214
228
  addTokens(c, row.covered);
215
229
  c._models[model] = (c._models[model] ?? 0) + row.covered.requests;
@@ -222,6 +236,7 @@ export function summarizeLedgerConsumers(file, now = Date.now()) {
222
236
  }
223
237
  if (row.metered) {
224
238
  c.meteredCost += costOfTokens(model, at, row.metered);
239
+ c.thinkingCost += costOfTokens(model, at, thinkingOnly(row.metered));
225
240
  c.requests += row.metered.requests;
226
241
  addTokens(c, row.metered);
227
242
  c._models[model] = (c._models[model] ?? 0) + row.metered.requests;
@@ -239,6 +254,8 @@ export function summarizeLedgerConsumers(file, now = Date.now()) {
239
254
  outputTokens: c.outputTokens,
240
255
  cacheReadTokens: c.cacheReadTokens,
241
256
  cacheCreateTokens: c.cacheCreateTokens,
257
+ thinkingTokens: c.thinkingTokens,
258
+ thinkingCost: round(c.thinkingCost),
242
259
  recent: { today: round(c.recent.today), last7d: round(c.recent.last7d), last30d: round(c.recent.last30d) },
243
260
  lastDay: c.lastDay,
244
261
  models: Object.entries(c._models).sort((a, b) => b[1] - a[1]).map(([m]) => m),
@@ -276,6 +293,11 @@ function addTokens(into, cell) {
276
293
  into.outputTokens += cell.outputTokens;
277
294
  into.cacheReadTokens += cell.cacheReadTokens;
278
295
  into.cacheCreateTokens += cell.cacheCreateTokens;
296
+ into.thinkingTokens += cell.thinkingTokens ?? 0;
297
+ }
298
+ /** The cell's thinking alone, shaped for the price list: output tokens and nothing else. */
299
+ function thinkingOnly(cell) {
300
+ return { inputTokens: 0, outputTokens: cell.thinkingTokens ?? 0, cacheReadTokens: 0, cacheCreateTokens: 0 };
279
301
  }
280
302
  // Round BEFORE choosing the unit. Picking the unit on the raw value and
281
303
  // rounding afterwards let 999_600 print as "1000k": once the rounded
@@ -305,32 +327,36 @@ export function summarizeLedger(file, path, now = Date.now()) {
305
327
  anthropic: { requests: 0, apiEquivalentCost: 0 },
306
328
  openai: { requests: 0, apiEquivalentCost: 0 },
307
329
  };
308
- const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
330
+ const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0, thinking: 0 };
309
331
  let requests = 0;
310
332
  let covered = 0;
311
333
  let metered = 0;
334
+ let thinkingCost = 0;
312
335
  const recent = { today: 0, last7d: 0, last30d: 0 };
313
336
  for (const [day, models] of Object.entries(file.days)) {
314
337
  const at = dayMs(day);
315
338
  for (const [model, row] of Object.entries(models)) {
316
339
  const m = (perModel[model] ??= {
317
340
  provider: providerOfModel(model), requests: 0,
318
- inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0,
341
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
319
342
  apiEquivalentCost: 0, meteredCost: 0,
320
343
  });
321
344
  if (row.covered) {
322
345
  const cost = costOfTokens(model, at, row.covered);
323
346
  covered += cost;
347
+ thinkingCost += costOfTokens(model, at, thinkingOnly(row.covered));
324
348
  m.apiEquivalentCost += cost;
325
349
  m.requests += row.covered.requests;
326
350
  m.inputTokens += row.covered.inputTokens;
327
351
  m.outputTokens += row.covered.outputTokens;
328
352
  m.cacheReadTokens += row.covered.cacheReadTokens;
329
353
  m.cacheCreateTokens += row.covered.cacheCreateTokens;
354
+ m.thinkingTokens += row.covered.thinkingTokens ?? 0;
330
355
  tokens.input += row.covered.inputTokens;
331
356
  tokens.output += row.covered.outputTokens;
332
357
  tokens.cacheRead += row.covered.cacheReadTokens;
333
358
  tokens.cacheCreate += row.covered.cacheCreateTokens;
359
+ tokens.thinking += row.covered.thinkingTokens ?? 0;
334
360
  perProvider[m.provider].requests += row.covered.requests;
335
361
  perProvider[m.provider].apiEquivalentCost += cost;
336
362
  requests += row.covered.requests;
@@ -364,6 +390,7 @@ export function summarizeLedger(file, path, now = Date.now()) {
364
390
  apiEquivalentCost: round(covered),
365
391
  meteredCost: round(metered),
366
392
  tokens,
393
+ thinkingCost: round(thinkingCost),
367
394
  perProvider,
368
395
  perModel,
369
396
  recent: { today: round(recent.today), last7d: round(recent.last7d), last30d: round(recent.last30d) },
@@ -559,8 +586,14 @@ export function formatLedgerSummary(s) {
559
586
  lines.push(` Today ${formatUsd(s.recent.today)} · Last 7d ${formatUsd(s.recent.last7d)} · Last 30d ${formatUsd(s.recent.last30d)}`);
560
587
  if (s.meteredCost > 0)
561
588
  lines.push(` Paid per token on top (API key / extra usage): ${formatUsd(s.meteredCost)}`);
589
+ if (s.tokens.thinking > 0)
590
+ lines.push(` Thinking: ${formatTokenCount(s.tokens.thinking)} of ${formatTokenCount(s.tokens.output)} output tokens (${thinkingPercent(s.tokens.thinking, s.tokens.output)}%) · ${formatUsd(s.thinkingCost)} of the number above`);
562
591
  return lines;
563
592
  }
593
+ /** Thinking as a whole percent of output; 0 when there was no output. */
594
+ export function thinkingPercent(thinking, output) {
595
+ return output > 0 ? Math.round((100 * thinking) / output) : 0;
596
+ }
564
597
  /**
565
598
  * `dario usage --by-key`: the lifetime number per consumer, biggest first.
566
599
  * A consumer is a named key's name, an `x-dario-consumer` header, or the
@@ -576,7 +609,8 @@ export function formatLedgerConsumers(s, limit = 20) {
576
609
  for (const [consumer, c] of entries.slice(0, limit)) {
577
610
  const models = c.models.slice(0, 2).map(shortModelName).join(', ');
578
611
  lines.push(` ${consumer.slice(0, width).padEnd(width)} ${formatUsd(c.apiEquivalentCost).padStart(9)} · ${formatUsd(c.recent.today).padStart(8)} · ${formatUsd(c.recent.last7d).padStart(8)} · ${formatUsd(c.recent.last30d).padStart(8)} ${c.requests.toLocaleString('en-US')} req${c.requests === 1 ? '' : 's'}${models ? `, ${models}` : ''}${c.meteredCost > 0 ? `, ${formatUsd(c.meteredCost)} metered` : ''}`);
579
- lines.push(` ${' '.repeat(width)} in ${formatTokenCount(c.inputTokens)} · out ${formatTokenCount(c.outputTokens)} · cache read ${formatTokenCount(c.cacheReadTokens)} · cache write ${formatTokenCount(c.cacheCreateTokens)}`);
612
+ const thinking = c.thinkingTokens > 0 ? ` · thinking ${formatTokenCount(c.thinkingTokens)} (${thinkingPercent(c.thinkingTokens, c.outputTokens)}% of out, ${formatUsd(c.thinkingCost)})` : '';
613
+ lines.push(` ${' '.repeat(width)} in ${formatTokenCount(c.inputTokens)} · out ${formatTokenCount(c.outputTokens)} · cache read ${formatTokenCount(c.cacheReadTokens)} · cache write ${formatTokenCount(c.cacheCreateTokens)}${thinking}`);
580
614
  }
581
615
  if (entries.length > limit)
582
616
  lines.push(` … and ${entries.length - limit} more`);
@@ -451,7 +451,7 @@ export function loadTemplate(_options) {
451
451
  // Stale cache: prefer whichever of the live cache and the bundled
452
452
  // snapshot was captured more recently — do NOT blindly keep the cache.
453
453
  // A frozen live cache must not shadow a newer bundled template, which is
454
- // exactly what happens in a no-CC deployment (e.g. the Hetzner container):
454
+ // exactly what happens in a no-CC deployment (e.g. a container deploy):
455
455
  // the async refresh can never run there, so the cache stays pinned at its
456
456
  // last capture while shipped releases move the bundle ahead. Without this
457
457
  // comparison, every bundled-template update is silently ignored until the
@@ -1069,7 +1069,7 @@ const STATIC_HEADER_EXCLUDE = new Set([
1069
1069
  // Found 2026-07-25: the bundled template had been baked on Windows for
1070
1070
  // several releases (#820/#828/#840/#849/#851 -> x-stainless-os: Windows,
1071
1071
  // 30 tools incl. PowerShell), while cc-drift-template-watch runs its live
1072
- // capture every 30 min on the Linux Hetzner runner. So the Linux box served
1072
+ // capture every 30 min on a Linux self-hosted runner. So the Linux box served
1073
1073
  // `x-stainless-os: Windows`, and the watch saw permanent drift against a
1074
1074
  // bundle it could never match -- auto-rebaking to Linux (#852), which the next
1075
1075
  // Windows-side bake (#854) would flip straight back. Excluding these ends
package/dist/proxy.js CHANGED
@@ -21,7 +21,7 @@ import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCo
21
21
  import { backfillIdentity } from './accounts.js';
22
22
  import { PoolSync, DEFAULT_POOL_SYNC_INTERVAL_MS } from './pool-sync.js';
23
23
  import { AccountSync, DEFAULT_ACCOUNT_SYNC_INTERVAL_MS } from './account-sync.js';
24
- import { Analytics, billingBucketFromClaim, costOfTokensFailClosed, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM, CREDITS_CLAIM } from './analytics.js';
24
+ import { Analytics, billingBucketFromClaim, costOfTokensFailClosed, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, thinkingTokensFromUsage, CODEX_CLAIM, CREDITS_CLAIM } from './analytics.js';
25
25
  import { Ledger, resolveLedgerPath, ledgerDisabledByEnv } from './ledger.js';
26
26
  import { renderPrometheus } from './metrics.js';
27
27
  import { renderSpendDonuts, renderAnalyticsView, analyticsUiPage } from './donuts.js';
@@ -36,7 +36,8 @@ import { createTokenBucket } from './rate-limit.js';
36
36
  import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI, isMiniMaxBackend, MINIMAX_MODELS } from './openai-backend.js';
37
37
  import { forwardToCodex, forwardResponsesToCodex, getCodexModelSlugs, peekCodexModelSlugs, isCodexModel, pickCodexFallback, pickClaudeTarget, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
38
38
  import { formatCacheTraceLine } from './cache-trace.js';
39
- import { effortForCodex } from './effort.js';
39
+ import { effortForCodex, requestNamesEffort, withResponsesEffort } from './effort.js';
40
+ import { thinkingToReasoningEffort } from './anthropic-responses-translate.js';
40
41
  import { MidstreamGuard, guardFor, loopbackBaseFor, chaosCutFetch, chaosCutState, CONTINUATION_HEADER, CONTINUATION_OF_HEADER, MAX_CONTINUATION_DEPTH, continuationDepth, continuationOfRequest } from './midstream.js';
41
42
  /**
42
43
  * The continuation fields for a request's analytics row, from its guard.
@@ -2646,7 +2647,7 @@ export async function startProxy(opts = {}) {
2646
2647
  * account, an unreadable one, or a `poolFallbackModel` that account does not
2647
2648
  * list all mean "not mine to serve".
2648
2649
  *
2649
- * Works for BOTH wire shapes, which is the point: forge's agents and Claude
2650
+ * Works for BOTH wire shapes, which is the point: SDK-engine agents and Claude
2650
2651
  * Code speak Anthropic, and before v5.5.87 they had nowhere to fail over to
2651
2652
  * and simply went dark when the Claude pool filled. The substituted model is
2652
2653
  * announced on `x-dario-pool-fallback` exactly as the api-key path does —
@@ -2788,7 +2789,7 @@ export async function startProxy(opts = {}) {
2788
2789
  * versions of this logic, and v6.0.0 wired the fallback into only ONE of
2789
2790
  * them. The other kept returning the raw upstream 429 — and that unwired
2790
2791
  * site is the one reached by every non-passthrough request, i.e. everything
2791
- * except a literal Claude Code CLI session, forge's SDK Engine included.
2792
+ * except a literal Claude Code CLI session, SDK-engine clients included.
2792
2793
  * That is what left the whole fleet unable to fail over during the live
2793
2794
  * outage on 2026-08-30 while `dario doctor` reported failover armed.
2794
2795
  *
@@ -4351,6 +4352,26 @@ export async function startProxy(opts = {}) {
4351
4352
  // Nothing below is allowed to depend on it: the tee only observes bytes
4352
4353
  // already on their way out, the comparison is never awaited by the
4353
4354
  // request, and every failure path resolves rather than throws.
4355
+ // A named key's default effort (`dario keys create --effort=low`) fills in
4356
+ // when nobody named one: no suffix on the model, no effort field in the
4357
+ // body. --effort / DARIO_EFFORT keeps winning over it, as it does over the
4358
+ // client, and a client that set its own effort is never overruled by a
4359
+ // default. Why this knob is per key: a reasoning model at its default
4360
+ // effort can spend most of its output thinking on work that did not need
4361
+ // it, and the lane that needs the cap is one consumer, not the proxy.
4362
+ // A Responses client names its effort in `reasoning.effort`, which the
4363
+ // translation above turns into a model suffix, so the raw body is read too.
4364
+ const keyDefaultEffort = requestAuth.key?.effort && !requestNamesEffort(parsedBody) && !requestNamesEffort(responsesBodyRaw)
4365
+ ? requestAuth.key.effort : undefined;
4366
+ // The pin outranks a key default only when it names a level. `client`
4367
+ // means "leave the client's choice alone", and a key default exists only
4368
+ // when the client chose nothing, so under `client` the default applies.
4369
+ const pinnedEffort = opts.effort === 'client' && keyDefaultEffort ? undefined : opts.effort;
4370
+ // On the Codex translated route a client's thinking budget IS its effort
4371
+ // choice (it becomes `reasoning.effort` there), so a key default must not
4372
+ // replace it. The proxy pin has never reached the Codex backend.
4373
+ const codexKeyEffort = keyDefaultEffort && thinkingToReasoningEffort(parsedBody?.thinking) === undefined
4374
+ ? keyDefaultEffort : undefined;
4354
4375
  const compareTarget = readCompareTarget(req.headers);
4355
4376
  if (compareTarget) {
4356
4377
  const compareShape = isOpenAI ? 'openai' : 'anthropic';
@@ -4670,14 +4691,18 @@ export async function startProxy(opts = {}) {
4670
4691
  // outage this change exists to remove, surviving on the one shape Codex
4671
4692
  // CLI actually speaks (caught in review of #1288).
4672
4693
  if (isResponses && responsesBodyRaw) {
4673
- served = await forwardResponsesToCodex(rawRes, { ...responsesBodyRaw, model: rawModel }, codexSeat, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, codexFetch, codexOnDone, codexOnDecline, canDefer || codexPeer !== null, { queueMs, arrivedAt });
4694
+ served = await forwardResponsesToCodex(
4695
+ // The body as written, plus `reasoning.effort` from the model-name
4696
+ // suffix or the named key's default when the client set none.
4697
+ rawRes, withResponsesEffort({ ...responsesBodyRaw, model: rawModel }, effortForCodex(requestEffort ?? keyDefaultEffort)), codexSeat, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, codexFetch, codexOnDone, codexOnDecline, canDefer || codexPeer !== null, { queueMs, arrivedAt });
4674
4698
  }
4675
4699
  else {
4676
4700
  served = await forwardToCodex(req, res, body, codexSeat, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', codexFetch, canDefer || codexPeer !== null, codexOnDone, codexOnDecline,
4677
4701
  // dario#1260 — the effort named by the model-name suffix stripped
4678
- // above. Undefined for every request that did not name one, which
4679
- // leaves the outbound body exactly as it was.
4680
- effortForCodex(requestEffort), codexGuard, { queueMs, arrivedAt });
4702
+ // above, else the named key's default unless the client's thinking
4703
+ // budget already picks one. Undefined for every request that has
4704
+ // neither, which leaves the outbound body exactly as it was.
4705
+ effortForCodex(requestEffort ?? codexKeyEffort), codexGuard, { queueMs, arrivedAt });
4681
4706
  }
4682
4707
  if (served || !codexPeer)
4683
4708
  break;
@@ -4978,7 +5003,7 @@ export async function startProxy(opts = {}) {
4978
5003
  sanitizeMessages(client, opts.preserveOrchestrationTags);
4979
5004
  const r = (isOpenAI ? openaiToAnthropic(client, target.model) : { ...client, model: target.model });
4980
5005
  r.model = stripContext1mTag(typeof r.model === 'string' ? r.model : target.model);
4981
- const effort = requestEffort ?? opts.effort;
5006
+ const effort = requestEffort ?? pinnedEffort ?? keyDefaultEffort;
4982
5007
  if (effort) {
4983
5008
  const oc = (r.output_config && typeof r.output_config === 'object' && !Array.isArray(r.output_config))
4984
5009
  ? { ...r.output_config }
@@ -5128,7 +5153,7 @@ export async function startProxy(opts = {}) {
5128
5153
  hybridTools: opts.hybridTools ?? false,
5129
5154
  mergeTools: opts.mergeTools ?? false,
5130
5155
  noAutoDetect: opts.noAutoDetect ?? false,
5131
- effort: requestEffort ?? opts.effort,
5156
+ effort: requestEffort ?? pinnedEffort ?? keyDefaultEffort,
5132
5157
  maxTokens: opts.maxTokens,
5133
5158
  systemPrompt: opts.systemPrompt,
5134
5159
  skipFields,
@@ -6210,6 +6235,9 @@ export async function startProxy(opts = {}) {
6210
6235
  let streamCacheReadTokens = 0;
6211
6236
  let streamCacheCreateTokens = 0;
6212
6237
  let streamThinkingChars = 0;
6238
+ // The billed count from message_delta's usage.output_tokens_details,
6239
+ // when upstream sends it; the chars/4 estimate above is the fallback.
6240
+ let streamThinkingTokens;
6213
6241
  const analyticsDecoder = analytics ? new TextDecoder() : null;
6214
6242
  let analyticsBuffer = '';
6215
6243
  // Stream SSE chunks through
@@ -6317,6 +6345,9 @@ export async function startProxy(opts = {}) {
6317
6345
  const u = e.usage;
6318
6346
  if (u?.output_tokens)
6319
6347
  streamOutputTokens = u.output_tokens;
6348
+ const billedThinking = thinkingTokensFromUsage(u);
6349
+ if (billedThinking !== undefined)
6350
+ streamThinkingTokens = billedThinking;
6320
6351
  }
6321
6352
  else if (e.type === 'content_block_delta') {
6322
6353
  // Mirror the non-streaming parseUsage thinking-token
@@ -6422,7 +6453,7 @@ export async function startProxy(opts = {}) {
6422
6453
  model: requestModel,
6423
6454
  inputTokens: streamInputTokens, outputTokens: streamOutputTokens,
6424
6455
  cacheReadTokens: streamCacheReadTokens, cacheCreateTokens: streamCacheCreateTokens,
6425
- thinkingTokens: Math.round(streamThinkingChars / 4),
6456
+ thinkingTokens: streamThinkingTokens ?? Math.round(streamThinkingChars / 4),
6426
6457
  claim: claimNow(rl), util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
6427
6458
  latencyMs: Date.now() - startTime, status: upstream.status, isStream: true, isOpenAI, timing: timingNow(),
6428
6459
  continuation: continuationOf(guard, requestDepth),
package/docs/admin-api.md CHANGED
@@ -94,8 +94,9 @@ All endpoints accept the token as `authorization: Bearer <token>` or
94
94
  | `GET /admin/accounts` | — | `{ accounts: [...], count }` |
95
95
  | `DELETE /admin/accounts/<alias>` | — | `{ alias, removed }` (`404` if no such alias) |
96
96
  | `GET /admin/keys` | — | `{ keys: [...], count, path }` — every named key, hashes excluded (v6.8) |
97
- | `POST /admin/keys` | `{ "name": string, "seat"?: string, "models"?: string[] \| "a,b", "expires"?: "30d" \| ISO }` | `201 { key, secret }` — the secret once; `409` if the name exists |
97
+ | `POST /admin/keys` | `{ "name": string, "seat"?: string, "models"?: string[] \| "a,b", "expires"?: "30d" \| ISO, "effort"?: "low" \| "medium" \| "high" \| "xhigh" \| "max" }` | `201 { key, secret }`, the secret shown once; `409` if the name exists |
98
98
  | `POST /admin/keys/<name>/rotate` | — | `{ key, secret }` — a new secret, the old one dead at once (`404` if unknown) |
99
+ | `POST /admin/keys/<name>/effort` | `{ "effort": "low" \| "medium" \| "high" \| "xhigh" \| "max" \| null }` | `{ key }` (`404` if unknown); null or absent clears the default |
99
100
  | `DELETE /admin/keys/<name>` | — | `{ name, revoked }` — refused from now on, kept in the list (`404` if unknown) |
100
101
 
101
102
  The three `/admin/keys` mutations edit the same `~/.dario/keys.json` that
@@ -93,9 +93,10 @@ Off by default, each one a deliberate divergence from what real CC sends.
93
93
 
94
94
  ### Per-request effort, by model name
95
95
 
96
- `DARIO_EFFORT` is process-wide: it applies to every caller, Claude and Codex
97
- alike. A client that has no way to set `output_config.effort` can instead name
98
- the level in the model itself, and only that request changes:
96
+ `DARIO_EFFORT` is process-wide: it applies to every caller on the Claude path
97
+ (it does not reach the Codex backend; a suffix or a key default does). A
98
+ client that has no way to set `output_config.effort` can instead name the
99
+ level in the model itself, and only that request changes:
99
100
 
100
101
  ```
101
102
  claude-opus-4-8:high colon form
@@ -118,6 +119,12 @@ lists, so a real model id that happens to end in an effort word is routed
118
119
  exactly as written and never re-read. A `--pool-fallback` chain entry may carry
119
120
  one too, which sets the effort the failover request runs at.
120
121
 
122
+ A named key can carry a default as well (`dario keys create <name> --effort=low`,
123
+ `dario keys effort <name> low`). It applies when the request names no effort of
124
+ its own: below the suffix, the operator pin and the client's own field, above
125
+ the `high` fallback. On the Codex path it is added to the Responses body Codex
126
+ CLI sends as well. See [keys.md](keys.md#default-effort).
127
+
121
128
  Effort is not free. Measured through dario on one prompt with only the level
122
129
  changing: `low` returned 1,982 output tokens in 18s, `high` 3,947 in 35s, and
123
130
  `max` 11,160 in 100s. Reasoning is billed as output, so `max` is a real cost
@@ -219,7 +219,7 @@ sudo npm i -g @anthropic-ai/claude-code @askalf/dario
219
219
  # URL back into the SSH session.
220
220
  #
221
221
  # SHARE the credential with any other CC clients that auto-refresh
222
- # (e.g. a platform dario container running 24/7). Just run the standard
222
+ # (e.g. a dario container running 24/7). Just run the standard
223
223
  # `dario login --manual` against /root/.claude/.credentials.json and let
224
224
  # that long-running refresh authority keep the token fresh. The workflows
225
225
  # read whatever's current at fire time and never attempt a refresh from
@@ -235,7 +235,7 @@ sudo npm i -g @anthropic-ai/claude-code @askalf/dario
235
235
  #
236
236
  # Sharing with a 24/7 refresh authority (typical setup: the docker-stack
237
237
  # dario container) fixes that. The race the isolation was protecting
238
- # against is rare in practice — platform dario refreshes proactively
238
+ # against is rare in practice: a long-running dario refreshes proactively
239
239
  # when the access token has ~1h life remaining, so workflow runs hit a
240
240
  # fresh token and don't need to refresh themselves.
241
241
  dario login --manual
@@ -332,13 +332,13 @@ Workflows that exercise live `dario proxy` paths (compat-test, billing canary, f
332
332
  | `cc-billing-classifier-canary.yml` | daily 06:30 UTC | daily | 1 small haiku request |
333
333
  | `compat-test-self-hosted.yml` | per qualifying PR | per qualifying PR | ~11 small requests |
334
334
 
335
- **Cron scheduler reality.** GitHub Actions' free-tier cron scheduler is best-effort, not guaranteed. The class-B watcher declares `*/30 * * * *` but in practice GitHub honors it every 2-4 hours on this repo. The liveness alarm (added v4.4.2) has its threshold set to 8h (raised from 3h in v4.7.1) to absorb this skew — anything past that is signal, not scheduler noise. If you need a tighter SLA (sub-hour), self-host the runner *and* the cron driver (e.g. a cron entry on the same Hetzner box invoking `gh workflow run` directly).
335
+ **Cron scheduler reality.** GitHub Actions' free-tier cron scheduler is best-effort, not guaranteed. The class-B watcher declares `*/30 * * * *` but in practice GitHub honors it every 2-4 hours on this repo. The liveness alarm (added v4.4.2) has its threshold set to 8h (raised from 3h in v4.7.1) to absorb this skew; anything past that is signal, not scheduler noise. If you need a tighter SLA (sub-hour), self-host the runner *and* the cron driver (e.g. a cron entry on the same host invoking `gh workflow run` directly).
336
336
 
337
337
  At steady state, this is comfortably inside Pro/Max headroom. The failure mode to watch for is **batched firing** — manually re-triggering the same workflow several times in a single hour, or PRs landing in rapid succession that each fire compat-test. We tripped this during the v4.6.x rollout: a half-dozen manual re-runs in a 2-hour window 429'd the runner credential. Pro/Max accounts have per-hour rate caps as well as per-5h / per-7d pools, and the per-hour cap is what surfaces first.
338
338
 
339
339
  If the runner credential is rate-limited and a workflow run reports 429s across the board, the right diagnosis order is: (a) check `claude --print` directly — if it 429s, the credential pool is dry, just wait an hour; (b) check the credential is still on a subscription account (`dario doctor`); (c) check workflow cadence assumptions haven't changed.
340
340
 
341
- The runner shares its OAuth credential with any other long-running CC client on the box (typically the platform dario container, which auto-refreshes 24/7). Sharing is intentional: a workflow that fires sparsely cannot keep its own refresh token alive — Anthropic invalidates idle refresh tokens, and `invalid_grant` then breaks every subsequent run. Letting a 24/7 refresh authority own the token rotation eliminates that failure mode at the cost of competing for the same Pro/Max headroom. With current cadence (drift-template-watch every 30 min + compat per PR + canary daily), runner-side burn on the shared account is a manageable fraction of the headroom available on a Max plan; reducing cadence further is a knob if a particular workload needs more of it.
341
+ The runner shares its OAuth credential with any other long-running CC client on the box (typically a long-running dario container, which auto-refreshes 24/7). Sharing is intentional: a workflow that fires sparsely cannot keep its own refresh token alive: Anthropic invalidates idle refresh tokens, and `invalid_grant` then breaks every subsequent run. Letting a 24/7 refresh authority own the token rotation eliminates that failure mode at the cost of competing for the same Pro/Max headroom. With current cadence (drift-template-watch every 30 min + compat per PR + canary daily), runner-side burn on the shared account is a manageable fraction of the headroom available on a Max plan; reducing cadence further is a knob if a particular workload needs more of it.
342
342
 
343
343
  ## Why a self-hosted runner
344
344
 
package/docs/keys.md CHANGED
@@ -34,12 +34,45 @@ on its next request, no restart.
34
34
  | `--expires=30d` | Refused after this, like a revoked key. `12h`, `2w`, or an ISO date. |
35
35
  | `--budget=$5/day` | A daily cap on the API-equivalent price of the key's traffic. See **Budgets**. |
36
36
  | `--budget-tokens=2M/day` | A daily cap on tokens, every bucket counted. See **Budgets**. |
37
+ | `--effort=low` | The effort this key's requests run at when they name none themselves (no model-name suffix, no effort field). `low`, `medium`, `high`, `xhigh` or `max`. See **Default effort**. |
37
38
 
38
39
  `dario keys revoke <name>` refuses a key from now on and keeps it in the list;
39
40
  `dario keys rotate <name>` prints a new secret under the same name, seat,
40
41
  models and expiry, and the old secret stops at once; `dario keys remove
41
42
  <name>` forgets it.
42
43
 
44
+ ## Default effort
45
+
46
+ `dario keys create alice --effort=low`, or `dario keys effort alice low` on an
47
+ existing key (`--clear` to remove it), sets the effort the key's requests run
48
+ at when they name none themselves. Resolution per request, first match wins:
49
+
50
+ 1. an effort suffix on the model name (`claude-opus-5-5:low`);
51
+ 2. the proxy's `--effort` / `DARIO_EFFORT` pin, when set to a level (`client`
52
+ is not a level: under it the key's default still fills in);
53
+ 3. the client's own effort field (`output_config.effort`, `reasoning_effort`, `reasoning.effort`);
54
+ 4. the key's default;
55
+ 5. `high`, the proxy's long-standing fallback.
56
+
57
+ So a key default never overrules a client that chose, and the operator's pin
58
+ still overrules everything, as it always has. On the Claude path the level
59
+ goes out as `output_config.effort`.
60
+
61
+ On the Codex path it goes out as `reasoning.effort`, on both routes: the
62
+ Messages-shape request dario translates, and the Responses body Codex CLI
63
+ sends, which dario otherwise forwards as written (the level is added to its
64
+ `reasoning` object; `summary` and the rest stay). Two differences there. The
65
+ proxy pin has never reached the Codex backend, so it is not in the order. And
66
+ a Messages-shape client's `thinking.budget_tokens` is that client's choice on
67
+ Codex (it becomes `reasoning.effort`), so it outranks the key's default the
68
+ way the client's own field does. With none of those, the backend's own
69
+ default applies, not `high`.
70
+ Why the knob is per key: a reasoning model at its default effort can spend
71
+ most of its output thinking on work that did not need it (the `Thinking:` line
72
+ in `dario usage` shows the share per key), and the lane that needs the cap is
73
+ usually one consumer, not the whole proxy. `dario keys list` shows an EFFORT
74
+ column; `GET /admin/keys` and `dario keys --json` carry `effort`.
75
+
43
76
  ## Budgets
44
77
 
45
78
  `dario keys create alice --budget=$5/day --budget-tokens=2M/day` — or
@@ -91,7 +124,9 @@ fields (an empty body clears), audited as `key_budget`.
91
124
  to the key; the header cannot overrule a credential.
92
125
  - **`dario usage --by-key`** — the lifetime API-equivalent number split per
93
126
  consumer: named keys, header names, and the `u_…` hash of a client's user
94
- id, whichever named the request.
127
+ id, whichever named the request. The tokens line under each key includes
128
+ its thinking share: how many of the key's output tokens were the model's
129
+ reasoning, and what they cost at the output rate.
95
130
  - **The log file** — `consumer` on every request line; `reject: "key-model"`
96
131
  on a model refusal; `event: "admin.key_create|key_rotate|key_revoke"` with
97
132
  `key: <name>` when the admin API changed something.
@@ -69,8 +69,11 @@ The rolling window forgets on every restart; the **ledger** does not. Since 6.6
69
69
  Claude $388 1,204 reqs (Opus 5 $301 · Sonnet 5 $86.68)
70
70
  ChatGPT $24.75 311 reqs (gpt-5.6-terra $24.75)
71
71
  Today $48.20 · Last 7d $413 · Last 30d $413
72
+ Thinking: 2.1M of 3.4M output tokens (62%) · $156 of the number above
72
73
  ```
73
74
 
75
+ The `Thinking:` line is the share of output tokens that were the model's reasoning, read from the usage block upstream returns on Claude traffic, and what that reasoning would have cost at each model's output rate. It is the first number to look at when a bill surprises you: a reasoning model at its default effort can spend most of its output thinking on work that did not need it, and `output_config.effort` (or a `:low` model-name suffix, see [configuration](configuration.md#per-request-effort-by-model-name)) is a cheaper fix than a smaller model. `dario usage --by-key` shows the share per key, so the lane that is thinking the most is one line away.
76
+
74
77
  Only served requests count. Traffic that was metered anyway — an API key upstream, or Anthropic's paid `extra_usage` overage — is kept in its own column and reported as spent, not saved. `dario usage --card` writes the headline as a 640×320 SVG you can drop in a README or a post, and `--donut` writes the same number as three rings — by model, by key, subscription vs metered; `--no-ledger` / `DARIO_LEDGER=0` turns the file off, `DARIO_LEDGER_PATH` moves it, and `GET /analytics/ledger` is the per-day table behind the number. Details: [api-equivalent-spend.md](api-equivalent-spend.md).
75
78
 
76
79
  A request served on a Max plan's API credits (the `credits:<model>` rung of the failover chain, key in `DARIO_CREDITS_API_KEY`, [failover](two-plans.md#failover-between-subscriptions)) is counted as spent the same way in the ledger; `/analytics`, `/metrics` and the dashboard show it under its own bucket, `credits`.
@@ -13,6 +13,8 @@ The empirical basis for this feature lives in [`docs/research/system-prompt-clas
13
13
  | `aggressive` | Partial + remove the prompt-level RLHF restatements (`IMPORTANT: Assist with authorized security testing…`, `IMPORTANT: You must NEVER generate or guess URLs…`) and the `# Executing actions with care` section. | <3% above partial |
14
14
  | `<file path>` | Replace the slot entirely with the contents of a file you control. The escape hatch for users running well-defined agent workflows with their own system prompt. | depends on your prompt |
15
15
 
16
+ What `partial` finds to strip depends on the prompt Claude Code sends for the model. The Sonnet 5 prompt has the `# Tone and style` and `# Text output` sections and the Doing-tasks bullets. The Fable prompt has a one-line rule about code comments, which `partial` swaps for a "be thorough" instruction. The base prompt (Claude Code 2.1.316) and the Opus 5 prompt carry none of these, so `partial` sends them unchanged and only `aggressive` changes them.
17
+
16
18
  ## Aggressive vs partial — what's the actual difference?
17
19
 
18
20
  Aggressive is provided for completeness, not because it does meaningful work. The added removals are *prompt-level restatements* of refusal categories — reminders the prompt makes about RLHF behavior that's already trained into the model's weights. Removing the reminder doesn't remove the trained behavior. We measured this: 9 trials (3 prompts × 3 strip levels), aggressive vs partial added <3% practical change on benign tasks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.19.2",
3
+ "version": "6.20.0",
4
4
  "description": "Use your Claude and ChatGPT subscriptions in Cursor, Cline, Aider, Claude Code and the Agent SDK at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint: either plan answers either wire shape, opt-in failover when one hits its limit, and a ledger of what each plan absorbed.",
5
5
  "type": "module",
6
6
  "bin": {