@askalf/dario 6.9.0 → 6.9.2

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
@@ -473,6 +473,7 @@ The split isn't live, but it was announced once on short notice and could return
473
473
  | Credentials | Your own subscription tokens, never logged, redacted from errors, `0600` on disk in `0700` dirs |
474
474
  | Network | Binds `127.0.0.1` by default; upstream only to configured backends over HTTPS; hardcoded SSRF allow-list; refuses a non-loopback bind without `DARIO_API_KEY` |
475
475
  | Telemetry | **None.** No analytics, no tracking, nothing phones home |
476
+ | Overhead | Measured in the open on every PR: [`scripts/bench-overhead.mjs`](./scripts/bench-overhead.mjs) runs a real proxy against an instant upstream beside a bare http server serving the same bytes. On loopback dario adds no measurable p50 wall time over that floor; the CPU per request is the number to watch, and the per-request [timing split](./docs/analytics.md#the-timing-split) shows it live |
476
477
  | This README | CI fails if the line count above drifts from `src/` or a link or anchor here stops resolving ([`check-readme-line-count.mjs`](./scripts/check-readme-line-count.mjs), [`check-readme-links.mjs`](./scripts/check-readme-links.mjs)); the TUI screenshots are rendered from the real TUI and the diagrams are briefed art, not screenshots ([how](./scripts/readme/README.md)) |
477
478
 
478
479
  ```bash
package/dist/pacing.d.ts CHANGED
@@ -141,3 +141,32 @@ export declare function resolveSessionStartConfig(explicit?: {
141
141
  jitterMs?: number;
142
142
  stealth?: boolean;
143
143
  }, env?: NodeJS.ProcessEnv): SessionStartConfig;
144
+ /**
145
+ * The governor's clocks, one set per seat (v6.9.1).
146
+ *
147
+ * The floors above model ONE account's cadence — that is what the provider
148
+ * observes. Through v6.9.0 the proxy kept a single `lastRequestTime` for the
149
+ * whole process, so a pool paced every seat against every other: a three-seat
150
+ * pool could put at most one request per floor on the wire, and a request to
151
+ * an idle seat waited for a stranger's request on a busy one (dario#1244's
152
+ * family — the pool running behind one seat's limits). Keying the clocks by
153
+ * seat keeps each account's observed rhythm exactly as before while the pool
154
+ * as a whole moves at pool speed. API-key mode has one seat and one clock.
155
+ */
156
+ export interface SeatPacingState {
157
+ /** When the seat's last request STARTED (feeds the inter-request floor). */
158
+ lastRequestTime: number;
159
+ /** When the seat's last 2xx response COMPLETED (feeds think-time). */
160
+ lastResponseTime: number;
161
+ /** Output tokens of that response (feeds think-time's per-token term). */
162
+ lastResponseTokens: number;
163
+ }
164
+ export declare class PacingRegistry {
165
+ private readonly seats;
166
+ /** The seat's clocks, created at zero on first sight (a fresh seat is never paced). */
167
+ seat(key: string): SeatPacingState;
168
+ /** Stamp a completed 2xx response on the seat, for the next request's think-time. */
169
+ noteResponse(key: string, at: number, outputTokens: number): void;
170
+ /** Seats the registry has seen, for tests and status. */
171
+ keys(): string[];
172
+ }
package/dist/pacing.js CHANGED
@@ -140,3 +140,25 @@ export function resolveSessionStartConfig(explicit = {}, env = process.env) {
140
140
  const jitter = pickNonNegativeInt(explicit.jitterMs, env.DARIO_SESSION_START_JITTER_MS) ?? (stealth ? 3000 : 0);
141
141
  return { minMs: min, jitterMs: jitter };
142
142
  }
143
+ export class PacingRegistry {
144
+ seats = new Map();
145
+ /** The seat's clocks, created at zero on first sight (a fresh seat is never paced). */
146
+ seat(key) {
147
+ let s = this.seats.get(key);
148
+ if (!s) {
149
+ s = { lastRequestTime: 0, lastResponseTime: 0, lastResponseTokens: 0 };
150
+ this.seats.set(key, s);
151
+ }
152
+ return s;
153
+ }
154
+ /** Stamp a completed 2xx response on the seat, for the next request's think-time. */
155
+ noteResponse(key, at, outputTokens) {
156
+ const s = this.seat(key);
157
+ s.lastResponseTime = at;
158
+ s.lastResponseTokens = outputTokens;
159
+ }
160
+ /** Seats the registry has seen, for tests and status. */
161
+ keys() {
162
+ return [...this.seats.keys()];
163
+ }
164
+ }
package/dist/proxy.d.ts CHANGED
@@ -636,6 +636,21 @@ export interface ProxyLogEntry {
636
636
  * null (logFile not configured). Errors are swallowed — log writes
637
637
  * must never break the request path.
638
638
  */
639
+ /**
640
+ * The `data:` line of one SSE frame, without allocating a per-line array
641
+ * (the frame is `event: x\ndata: {...}\n\n`; the data line is the one that
642
+ * starts with the field name, at the start of the frame or after a newline).
643
+ * Null when the frame has no data line (a comment, a bare event).
644
+ */
645
+ export declare function sseDataLine(frame: string): string | null;
646
+ /**
647
+ * Whether the analytics tap needs to parse this frame at all: only the
648
+ * message_start usage, the message_delta usage and thinking deltas feed a
649
+ * number it keeps. A text or tool-input delta — most of any stream — is
650
+ * skipped before JSON.parse. Substring tests on the data line; a frame that
651
+ * happens to contain these words inside a text delta merely costs a parse.
652
+ */
653
+ export declare function analyticsFrameOfInterest(dataLine: string): boolean;
639
654
  export declare function writeLogLine(stream: WriteStream | null, entry: ProxyLogEntry): void;
640
655
  export declare function sanitizeError(err: unknown): string;
641
656
  /**
package/dist/proxy.js CHANGED
@@ -990,6 +990,31 @@ export function requiresClaudeLogin(poolSize, adminEnabled, hasUpstreamApiKey, n
990
990
  * null (logFile not configured). Errors are swallowed — log writes
991
991
  * must never break the request path.
992
992
  */
993
+ /**
994
+ * The `data:` line of one SSE frame, without allocating a per-line array
995
+ * (the frame is `event: x\ndata: {...}\n\n`; the data line is the one that
996
+ * starts with the field name, at the start of the frame or after a newline).
997
+ * Null when the frame has no data line (a comment, a bare event).
998
+ */
999
+ export function sseDataLine(frame) {
1000
+ let at = frame.startsWith('data: ') ? 0 : frame.indexOf('\ndata: ');
1001
+ if (at < 0)
1002
+ return null;
1003
+ if (at > 0)
1004
+ at += 1;
1005
+ const end = frame.indexOf('\n', at);
1006
+ return end < 0 ? frame.slice(at) : frame.slice(at, end);
1007
+ }
1008
+ /**
1009
+ * Whether the analytics tap needs to parse this frame at all: only the
1010
+ * message_start usage, the message_delta usage and thinking deltas feed a
1011
+ * number it keeps. A text or tool-input delta — most of any stream — is
1012
+ * skipped before JSON.parse. Substring tests on the data line; a frame that
1013
+ * happens to contain these words inside a text delta merely costs a parse.
1014
+ */
1015
+ export function analyticsFrameOfInterest(dataLine) {
1016
+ return dataLine.includes('"message_start"') || dataLine.includes('"message_delta"') || dataLine.includes('thinking_delta');
1017
+ }
993
1018
  export function writeLogLine(stream, entry) {
994
1019
  if (!stream)
995
1020
  return;
@@ -1896,15 +1921,13 @@ export async function startProxy(opts = {}) {
1896
1921
  // 500ms floor keeps the default behavior identical to v3.23; `--pace-min`
1897
1922
  // and `--pace-jitter` let callers tune the distribution. Pure calc lives
1898
1923
  // in src/pacing.ts so the edge cases are unit-tested without timers.
1899
- const { computePacingDelay, resolvePacingConfig, computeThinkTimeDelay, resolveThinkTimeConfig, computeSessionStartDelay, resolveSessionStartConfig, } = await import('./pacing.js');
1900
- let lastRequestTime = 0;
1901
- // Behavioral smoothing state: when the last response *completed* and
1902
- // how many output tokens it had. Used by computeThinkTimeDelay to
1903
- // model human read-time before the next request. Distinct from
1904
- // lastRequestTime (which tracks when the last request *started* and
1905
- // feeds the inter-request floor).
1906
- let lastResponseTime = 0;
1907
- let lastResponseTokens = 0;
1924
+ const { computePacingDelay, resolvePacingConfig, computeThinkTimeDelay, resolveThinkTimeConfig, computeSessionStartDelay, resolveSessionStartConfig, PacingRegistry, } = await import('./pacing.js');
1925
+ // The governor's clocks, PER SEAT (v6.9.1, src/pacing.ts PacingRegistry):
1926
+ // when a seat's last request started (the inter-request floor) and when its
1927
+ // last 2xx response completed with how many output tokens (think-time).
1928
+ // One clock for the whole proxy used to pace every seat against every
1929
+ // other, so a pool moved at one seat's speed. API-key mode is one seat.
1930
+ const pacingClocks = new PacingRegistry();
1908
1931
  // --stealth toggles the behavioral-stealth preset across all three
1909
1932
  // pacing layers (pace, think-time, session-start). When on, each
1910
1933
  // resolver's zero-default flips to its stealth preset; explicit flags
@@ -1932,7 +1955,7 @@ export async function startProxy(opts = {}) {
1932
1955
  if (verbose) {
1933
1956
  if (stealth)
1934
1957
  console.log('[dario] stealth: behavioral-stealth preset active (pace+think+session-start defaults non-zero)');
1935
- console.log(`[dario] pacing: min=${pacingCfg.minGapMs}ms jitter=${pacingCfg.jitterMs}ms`);
1958
+ console.log(`[dario] pacing: min=${pacingCfg.minGapMs}ms jitter=${pacingCfg.jitterMs}ms (per seat)`);
1936
1959
  if (thinkTimeEnabled) {
1937
1960
  console.log(`[dario] think-time: base=${thinkTimeCfg.baseMs}ms perToken=${thinkTimeCfg.perTokenMs}ms jitter=${thinkTimeCfg.jitterMs}ms max=${thinkTimeCfg.maxMs}ms`);
1938
1961
  }
@@ -4079,7 +4102,14 @@ export async function startProxy(opts = {}) {
4079
4102
  // hand the client an OpenAI-shaped response for a Messages request. This
4080
4103
  // route still has no reverse translation; the codex route above does,
4081
4104
  // which is why it takes both shapes and this one does not.
4082
- const fallbackModel = selectPoolFallbackForBody(body)[0] ?? null;
4105
+ // Only the drained-pool branch below reads this, and computing it means
4106
+ // parsing the whole client body again — on a 200 KB Claude Code turn
4107
+ // that was ~2% of dario's own CPU on every request that never took the
4108
+ // branch (scripts/bench-overhead.mjs profile, v6.9.2). Resolved only
4109
+ // when the branch can be taken.
4110
+ const fallbackModel = (!upstreamApiKey && !poolAccount && openaiBackend && isOpenAI)
4111
+ ? (selectPoolFallbackForBody(body)[0] ?? null)
4112
+ : null;
4083
4113
  if (!upstreamApiKey && !poolAccount && fallbackModel && openaiBackend && isOpenAI) {
4084
4114
  const fallbackBody = buildPoolFallbackBody(body, fallbackModel);
4085
4115
  if (!fallbackBody) {
@@ -4506,12 +4536,17 @@ export async function startProxy(opts = {}) {
4506
4536
  // Opt-in via --session-start-* flags.
4507
4537
  // We take the max because each layer enforces an independent floor
4508
4538
  // — waiting longer satisfies all of them, so we never need to sum.
4539
+ // The clocks are the selected seat's. A mid-flight failover to a peer
4540
+ // (below) does not re-pace on the peer: the retry is the rare path, and
4541
+ // holding it for a floor would stack a second wait on a request that
4542
+ // has already been refused once.
4543
+ const seatClock = pacingClocks.seat(poolAccount?.alias ?? ACCOUNT_KEY_APIKEY);
4509
4544
  const nowForPacing = Date.now();
4510
- const pacingDelay = computePacingDelay(nowForPacing, lastRequestTime, pacingCfg);
4545
+ const pacingDelay = computePacingDelay(nowForPacing, seatClock.lastRequestTime, pacingCfg);
4511
4546
  const thinkDelay = thinkTimeEnabled
4512
- ? computeThinkTimeDelay(nowForPacing, lastResponseTime, lastResponseTokens, thinkTimeCfg)
4547
+ ? computeThinkTimeDelay(nowForPacing, seatClock.lastResponseTime, seatClock.lastResponseTokens, thinkTimeCfg)
4513
4548
  : 0;
4514
- const sessionStartDelay = (sessionStartEnabled && lastResponseTime === 0 && lastRequestTime === 0)
4549
+ const sessionStartDelay = (sessionStartEnabled && seatClock.lastResponseTime === 0 && seatClock.lastRequestTime === 0)
4515
4550
  ? computeSessionStartDelay(sessionStartCfg)
4516
4551
  : 0;
4517
4552
  const totalDelay = Math.max(pacingDelay, thinkDelay, sessionStartDelay);
@@ -4519,7 +4554,7 @@ export async function startProxy(opts = {}) {
4519
4554
  pacingMs += totalDelay;
4520
4555
  await new Promise(r => setTimeout(r, totalDelay));
4521
4556
  }
4522
- lastRequestTime = Date.now();
4557
+ seatClock.lastRequestTime = Date.now();
4523
4558
  // Session ID: resolved through the rotation registry keyed by the selected
4524
4559
  // account (src/session-rotation.ts), applying the configured idle / jitter
4525
4560
  // / max-age / per-client policy. The template-build path resolves it
@@ -5284,14 +5319,22 @@ export async function startProxy(opts = {}) {
5284
5319
  upstreamDoneAt = Date.now();
5285
5320
  break;
5286
5321
  }
5287
- // Parse SSE events for analytics regardless of routing branch
5322
+ // Parse SSE events for analytics regardless of routing branch.
5323
+ // Only three frame kinds carry a number this tap reads — the
5324
+ // message_start usage, the message_delta usage, and thinking
5325
+ // deltas (for the ~4-chars-per-token estimate). Every other frame
5326
+ // is a text or tool delta, i.e. most of a stream, and parsing
5327
+ // them was the single largest cost in dario's own streaming path
5328
+ // (scripts/bench-overhead.mjs profile, v6.9.2). A substring test
5329
+ // on the data line decides before JSON.parse; the parse itself
5330
+ // is unchanged for the frames that pass.
5288
5331
  if (analyticsDecoder && value) {
5289
5332
  analyticsBuffer += analyticsDecoder.decode(value, { stream: true });
5290
5333
  const parts = analyticsBuffer.split('\n\n');
5291
5334
  analyticsBuffer = parts.pop() ?? '';
5292
5335
  for (const part of parts) {
5293
- const dataLine = part.split('\n').find(l => l.startsWith('data: '));
5294
- if (!dataLine)
5336
+ const dataLine = sseDataLine(part);
5337
+ if (!dataLine || !analyticsFrameOfInterest(dataLine))
5295
5338
  continue;
5296
5339
  try {
5297
5340
  const e = JSON.parse(dataLine.slice(6));
@@ -5401,8 +5444,7 @@ export async function startProxy(opts = {}) {
5401
5444
  // would read, and using their (often zero) output_tokens would
5402
5445
  // pin think time to baseMs+jitter on the next request needlessly.
5403
5446
  if (upstream.status >= 200 && upstream.status < 300) {
5404
- lastResponseTime = Date.now();
5405
- lastResponseTokens = streamOutputTokens;
5447
+ pacingClocks.noteResponse(poolAccount?.alias ?? ACCOUNT_KEY_APIKEY, Date.now(), streamOutputTokens);
5406
5448
  }
5407
5449
  {
5408
5450
  const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
@@ -5472,8 +5514,7 @@ export async function startProxy(opts = {}) {
5472
5514
  // tokens when the body wasn't JSON or had no usage block — base +
5473
5515
  // jitter still apply but the per-token component is 0.
5474
5516
  if (upstream.status >= 200 && upstream.status < 300) {
5475
- lastResponseTime = Date.now();
5476
- lastResponseTokens = bufferedUsage?.outputTokens ?? 0;
5517
+ pacingClocks.noteResponse(poolAccount?.alias ?? ACCOUNT_KEY_APIKEY, Date.now(), bufferedUsage?.outputTokens ?? 0);
5477
5518
  }
5478
5519
  if (bufferedUsage) {
5479
5520
  try {
@@ -6,7 +6,7 @@ Between v3.22 and v3.28, dario's Claude backend closed six axes along which a pr
6
6
  |---|---|---|---|
7
7
  | **Request body key order** | v3.22 | Top-level JSON key order of the outbound `/v1/messages` body is captured from CC's wire serialization and replayed byte-for-byte. Schema bumped v2 → v3; stale caches quarantined. | Automatic once a live capture exists. The baked fallback carries a v2.1.112 snapshot. |
8
8
  | **Runtime / TLS ClientHello** | v3.23 | Classifies the runtime as `bun-match` / `bun-ja3-unverified` / `bun-bypassed` / `node-only` and surfaces the class + hint in `dario doctor`. Bun yields the BoringSSL ClientHello CC presents; Node yields OpenSSL's (distinct JA3). Being on Bun is necessary but not sufficient — only Bun ≥ v1.3.14 is measured to reproduce CC's JA3, so an older Bun is flagged `bun-ja3-unverified` rather than green (#813). | `--strict-tls` (or `DARIO_STRICT_TLS=1`) refuses to start proxy mode unless `bun-match`. `DARIO_QUIET_TLS=1` silences the startup banner in known-fine environments. |
9
- | **Inter-request timing** | v3.24 | Replaces the hardcoded 500 ms floor with a configurable floor + uniform jitter. A fixed 500 ms minimum-inter-arrival is an observable edge at scale; jitter dissolves the edge. | `--pace-min=MS`, `--pace-jitter=MS`, or `DARIO_PACE_MIN_MS` / `DARIO_PACE_JITTER_MS`. Legacy `DARIO_MIN_INTERVAL_MS` still honored. |
9
+ | **Inter-request timing** | v3.24 | Replaces the hardcoded 500 ms floor with a configurable floor + uniform jitter. A fixed 500 ms minimum-inter-arrival is an observable edge at scale; jitter dissolves the edge. Since 6.9.1 the floor (and think-time / session-start) is kept **per seat**: the cadence the provider sees per account is unchanged, and a pool no longer paces its seats against each other. | `--pace-min=MS`, `--pace-jitter=MS`, or `DARIO_PACE_MIN_MS` / `DARIO_PACE_JITTER_MS`. Legacy `DARIO_MIN_INTERVAL_MS` still honored. |
10
10
  | **Stream-consumption shape** | v3.25 | When a downstream client disconnects mid-stream, CC keeps reading SSE to EOF. Dario now offers the same: drain upstream to completion even when the consumer has left. Default off — don't silently burn tokens. | `--drain-on-close` / `DARIO_DRAIN_ON_CLOSE=1`. Bounded by the existing 5-minute upstream timeout. |
11
11
  | **Session-ID lifecycle** | v3.28 | Generalizes the v3.19 hardcoded 15-minute idle rotation into a tunable `SessionRegistry` with jitter, max-age, and per-client bucketing. Fixes a v3.27 body/header rotation race as a side effect. | `--session-idle-rotate=MS` (default 900000), `--session-rotate-jitter=MS`, `--session-max-age=MS`, `--session-per-client`. Env mirrors `DARIO_SESSION_*`. Defaults are bit-identical to v3.27. |
12
12
  | **MCP / sub-agent reach** | v3.26 + v3.27 | Not a wire axis — a *surface* axis. CC-aware tools can now address dario directly (sub-agent from inside CC, MCP server for any MCP client), so operators don't have to switch terminals to introspect the proxy. Read-only by design. | `dario subagent install` / `dario mcp`. See [`mcp-server.md`](./mcp-server.md) and [`sub-agent.md`](./sub-agent.md). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.9.0",
3
+ "version": "6.9.2",
4
4
  "description": "Use your Claude and ChatGPT subscriptions in Cursor, Cline, Aider, Claude Code and the Agent SDK — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint: either plan answers either wire shape, with automatic failover when one hits its limit.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,7 +42,8 @@
42
42
  "fix:pkg": "node -e \"const fs=require('fs');fs.writeFileSync('package.json',JSON.stringify(JSON.parse(fs.readFileSync('package.json','utf-8')),null,2)+'\\n')\"",
43
43
  "audit:tui": "node tools/tui-audit/audit.mjs",
44
44
  "readme:assets": "node scripts/readme/terminal.mjs && node scripts/readme/tui.mjs",
45
- "check:readme": "node scripts/check-readme-line-count.mjs && node scripts/check-readme-links.mjs"
45
+ "check:readme": "node scripts/check-readme-line-count.mjs && node scripts/check-readme-links.mjs",
46
+ "bench": "node scripts/bench-overhead.mjs"
46
47
  },
47
48
  "keywords": [
48
49
  "llm",