@askalf/dario 6.6.0 → 6.6.1

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.
@@ -41,7 +41,39 @@ export interface RequestRecord {
41
41
  status: number;
42
42
  isStream: boolean;
43
43
  isOpenAI: boolean;
44
+ /**
45
+ * Set when the stream died with content on the wire and the mid-stream
46
+ * guard acted (v6.1, src/midstream.ts): what came of it and, when a resume
47
+ * delivered, which leg served the rest. Absent on every ordinary request.
48
+ */
49
+ continuation?: RequestContinuation;
50
+ }
51
+ export interface RequestContinuation {
52
+ /** See midstream.ts ContinuationOutcome. */
53
+ outcome: 'continued' | 'continued-unfinished' | 'resume-failed' | 'no-target';
54
+ /** Label of the leg that served the rest (`gpt-5.6-terra (codex)`), when one did. */
55
+ by?: string;
56
+ /** Characters the client already had when the stream died. */
57
+ partialChars: number;
58
+ }
59
+ /**
60
+ * How the continuations in a window went. `attempted` is every stream that
61
+ * died with content on the wire and a guard in place; the other four
62
+ * partition it. The number that says whether --pool-fallback is set up to
63
+ * catch a dying stream, and how often one dies at all.
64
+ */
65
+ export interface ContinuationStats {
66
+ attempted: number;
67
+ /** The client holds one complete message. */
68
+ finished: number;
69
+ /** A resume delivered content and then died too; the stream was left open-ended. */
70
+ unfinished: number;
71
+ /** Every choice delivered nothing; the stream ended truncated as before. */
72
+ failed: number;
73
+ /** Nothing to resume through — no --pool-fallback entry for the other provider. */
74
+ noTarget: number;
44
75
  }
76
+ export declare function continuationStats(records: readonly RequestRecord[]): ContinuationStats;
45
77
  /**
46
78
  * The four billing buckets a request can land in, derived from the
47
79
  * `anthropic-ratelimit-unified-representative-claim` response header.
@@ -316,6 +348,8 @@ interface WindowStats {
316
348
  estimatedCost: number;
317
349
  avgLatencyMs: number;
318
350
  errorRate: number;
351
+ /** Mid-stream continuations in the window and how they went (v6.1 guard, counted since v6.6.1). */
352
+ continuations: ContinuationStats;
319
353
  claimBreakdown: Record<string, number>;
320
354
  /** Count of requests in each derived billing bucket. See #34. */
321
355
  billingBucketBreakdown: Record<BillingBucket, number>;
package/dist/analytics.js CHANGED
@@ -18,6 +18,29 @@
18
18
  */
19
19
  import { EventEmitter } from 'node:events';
20
20
  import { createHash } from 'node:crypto';
21
+ export function continuationStats(records) {
22
+ const out = { attempted: 0, finished: 0, unfinished: 0, failed: 0, noTarget: 0 };
23
+ for (const r of records) {
24
+ if (!r.continuation)
25
+ continue;
26
+ out.attempted++;
27
+ switch (r.continuation.outcome) {
28
+ case 'continued':
29
+ out.finished++;
30
+ break;
31
+ case 'continued-unfinished':
32
+ out.unfinished++;
33
+ break;
34
+ case 'resume-failed':
35
+ out.failed++;
36
+ break;
37
+ case 'no-target':
38
+ out.noTarget++;
39
+ break;
40
+ }
41
+ }
42
+ return out;
43
+ }
21
44
  /**
22
45
  * Map the raw `representative-claim` header value to a human-friendly
23
46
  * billing bucket. Pure function; no state; safe to call from any context.
@@ -372,6 +395,7 @@ export class Analytics extends EventEmitter {
372
395
  totalInputTokens: 0, totalOutputTokens: 0, totalThinkingTokens: 0,
373
396
  totalCacheReadTokens: 0, totalCacheCreateTokens: 0, cachedPromptPercent: 0,
374
397
  estimatedCost: 0, avgLatencyMs: 0, errorRate: 0,
398
+ continuations: { attempted: 0, finished: 0, unfinished: 0, failed: 0, noTarget: 0 },
375
399
  claimBreakdown: {},
376
400
  billingBucketBreakdown: {
377
401
  subscription: 0,
@@ -418,6 +442,7 @@ export class Analytics extends EventEmitter {
418
442
  estimatedCost: Math.round(cost * 10000) / 10000,
419
443
  avgLatencyMs: Math.round(avgLatency),
420
444
  errorRate: Math.round((errors / records.length) * 10000) / 10000,
445
+ continuations: continuationStats(records),
421
446
  claimBreakdown: claims,
422
447
  billingBucketBreakdown: buckets,
423
448
  subscriptionPercent: subscriptionPct,
package/dist/cli.js CHANGED
@@ -2450,6 +2450,19 @@ async function usage() {
2450
2450
  if ((win.estimatedCost ?? 0) > 0) {
2451
2451
  console.log(` Est. cost: $${(win.estimatedCost ?? 0).toFixed(4)} (would-be API cost)`);
2452
2452
  }
2453
+ // Streams that died with content on the wire, and what the mid-stream
2454
+ // guard made of them. Silent when none did — the common case.
2455
+ const c = win.continuations;
2456
+ if (c && c.attempted > 0) {
2457
+ const parts = [`${c.finished} finished`];
2458
+ if (c.unfinished > 0)
2459
+ parts.push(`${c.unfinished} unfinished`);
2460
+ if (c.failed > 0)
2461
+ parts.push(`${c.failed} failed`);
2462
+ if (c.noTarget > 0)
2463
+ parts.push(`${c.noTarget} no target — set --pool-fallback for the other provider`);
2464
+ console.log(` Continuations: ${c.attempted} stream${c.attempted === 1 ? '' : 's'} died mid-answer: ${parts.join(', ')}`);
2465
+ }
2453
2466
  }
2454
2467
  if (perAccount && Object.keys(perAccount).length > 0) {
2455
2468
  console.log('');
@@ -65,6 +65,14 @@ export declare const CONTINUATION_HEADER = "x-dario-continuation";
65
65
  export declare const MAX_CONTINUATION_DEPTH = 2;
66
66
  /** The depth a request carries, 0 for an ordinary client request. */
67
67
  export declare function continuationDepth(headerValue: string | string[] | undefined): number;
68
+ /**
69
+ * The loopback also names the request it resumes (the guard's request
70
+ * number), so the resume leg's own log row can point back at the row whose
71
+ * stream died. Log-side only: nothing routes on it.
72
+ */
73
+ export declare const CONTINUATION_OF_HEADER = "x-dario-continuation-of";
74
+ /** The request number a resume leg names, or undefined when absent / not a number. */
75
+ export declare function continuationOfRequest(headerValue: string | string[] | undefined): number | undefined;
68
76
  /** Characters of the partial the model is asked to repeat verbatim (the seam anchor). */
69
77
  export declare const ANCHOR_CHARS = 40;
70
78
  export interface SseFrame {
@@ -304,11 +312,24 @@ export interface MidstreamGuardOptions {
304
312
  log?: (line: string) => void;
305
313
  }
306
314
  export type FinishOutcome = 'clean' | 'continued' | 'continued-unfinished' | 'ended' | 'not-continuable' | 'no-target' | 'resume-failed';
315
+ /**
316
+ * What a continuation attempt came to, for the request's analytics row and
317
+ * log line: the four FinishOutcomes where the guard actually acted. A clean
318
+ * stream, one the client left, and one that was never continuable are not
319
+ * attempts and are not recorded.
320
+ */
321
+ export type ContinuationOutcome = 'continued' | 'continued-unfinished' | 'resume-failed' | 'no-target';
307
322
  export declare class MidstreamGuard {
308
323
  private readonly o;
309
324
  readonly state: ClientStreamState;
310
325
  private readonly splitter;
311
326
  private finished;
327
+ /** Set by finish(): the attempt's outcome, or null when the stream needed none. */
328
+ outcome: ContinuationOutcome | null;
329
+ /** The label of the leg that put content on the wire (`gpt-5.6-terra (codex)`, `claude-opus-5 (same model)`). */
330
+ continuedBy: string | null;
331
+ /** Characters the client had when the stream died — where the seam sits. */
332
+ partialChars: number;
312
333
  constructor(o: MidstreamGuardOptions);
313
334
  /**
314
335
  * The site knows the upstream turn failed (a codex `response.failed`, a
package/dist/midstream.js CHANGED
@@ -69,6 +69,20 @@ export function continuationDepth(headerValue) {
69
69
  const n = Number.parseInt(v, 10);
70
70
  return Number.isFinite(n) && n > 0 ? n : 1;
71
71
  }
72
+ /**
73
+ * The loopback also names the request it resumes (the guard's request
74
+ * number), so the resume leg's own log row can point back at the row whose
75
+ * stream died. Log-side only: nothing routes on it.
76
+ */
77
+ export const CONTINUATION_OF_HEADER = 'x-dario-continuation-of';
78
+ /** The request number a resume leg names, or undefined when absent / not a number. */
79
+ export function continuationOfRequest(headerValue) {
80
+ const v = Array.isArray(headerValue) ? headerValue[0] : headerValue;
81
+ if (v === undefined)
82
+ return undefined;
83
+ const n = Number.parseInt(v, 10);
84
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
85
+ }
72
86
  /** Characters of the partial the model is asked to repeat verbatim (the seam anchor). */
73
87
  export const ANCHOR_CHARS = 40;
74
88
  /** Upper bound on continuation text held back while looking for the anchor. */
@@ -702,6 +716,12 @@ export class MidstreamGuard {
702
716
  state;
703
717
  splitter = new SseFrameSplitter();
704
718
  finished = false;
719
+ /** Set by finish(): the attempt's outcome, or null when the stream needed none. */
720
+ outcome = null;
721
+ /** The label of the leg that put content on the wire (`gpt-5.6-terra (codex)`, `claude-opus-5 (same model)`). */
722
+ continuedBy = null;
723
+ /** Characters the client had when the stream died — where the seam sits. */
724
+ partialChars = 0;
705
725
  constructor(o) {
706
726
  this.o = o;
707
727
  this.state = new ClientStreamState(o.shape);
@@ -762,6 +782,7 @@ export class MidstreamGuard {
762
782
  // dead before its first byte) hands over to the next; the first one that
763
783
  // puts content on the wire ends the search, finished or not.
764
784
  const partial = s.textSoFar;
785
+ this.partialChars = partial.length;
765
786
  let tried = 0;
766
787
  for (let choice = (this.o.depth ?? 0) + 1; choice <= MAX_CONTINUATION_DEPTH; choice++) {
767
788
  let target = null;
@@ -781,15 +802,19 @@ export class MidstreamGuard {
781
802
  continue;
782
803
  }
783
804
  this.o.end();
784
- return outcome === 'finished' ? 'continued' : 'continued-unfinished';
805
+ this.continuedBy = target.label;
806
+ this.outcome = outcome === 'finished' ? 'continued' : 'continued-unfinished';
807
+ return this.outcome;
785
808
  }
786
809
  if (tried === 0) {
787
810
  this.log(`#${this.o.requestNo} stream died after ${partial.length} chars — no continuation target (set --pool-fallback with an entry for the other provider)`);
788
811
  cleanEnd();
789
- return 'no-target';
812
+ this.outcome = 'no-target';
813
+ return this.outcome;
790
814
  }
791
815
  cleanEnd();
792
- return 'resume-failed';
816
+ this.outcome = 'resume-failed';
817
+ return this.outcome;
793
818
  }
794
819
  /**
795
820
  * 'failed': nothing of the resume reached the client — the site ends the
@@ -817,7 +842,7 @@ export class MidstreamGuard {
817
842
  r.onBeforeResume?.();
818
843
  const res = await fetchImpl(`${r.loopbackBase}${path}`, {
819
844
  method: 'POST',
820
- headers: { 'content-type': 'application/json', [CONTINUATION_HEADER]: String((this.o.depth ?? 0) + 1), ...r.loopbackHeaders },
845
+ headers: { 'content-type': 'application/json', [CONTINUATION_HEADER]: String((this.o.depth ?? 0) + 1), [CONTINUATION_OF_HEADER]: String(this.o.requestNo), ...r.loopbackHeaders },
821
846
  body: JSON.stringify(body),
822
847
  signal: abort.signal,
823
848
  });
package/dist/proxy.d.ts CHANGED
@@ -586,6 +586,15 @@ export interface ProxyLogEntry {
586
586
  client?: string;
587
587
  preserve_tools?: boolean;
588
588
  stream?: boolean;
589
+ /** Mid-stream continuation outcome, when this request's guard acted (see analytics RequestContinuation). */
590
+ continued?: 'continued' | 'continued-unfinished' | 'resume-failed' | 'no-target';
591
+ continued_by?: string;
592
+ /** Characters the client already had when the stream died. */
593
+ continued_after?: number;
594
+ /** On a resume leg: how deep it sits (1 = resume of a client request, 2 = resume of a resume). */
595
+ continuation_depth?: number;
596
+ /** On a resume leg: the request number whose stream it resumes (the guard's number). */
597
+ continuation_of?: number;
589
598
  reject?: string;
590
599
  error?: string;
591
600
  event?: string;
package/dist/proxy.js CHANGED
@@ -27,7 +27,19 @@ import { createTokenBucket } from './rate-limit.js';
27
27
  import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
28
28
  import { forwardToCodex, forwardResponsesToCodex, getCodexModelSlugs, peekCodexModelSlugs, isCodexModel, pickCodexFallback, pickClaudeTarget, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
29
29
  import { effortForCodex } from './effort.js';
30
- import { MidstreamGuard, guardFor, loopbackBaseFor, chaosCutFetch, chaosCutState, CONTINUATION_HEADER, MAX_CONTINUATION_DEPTH, continuationDepth } from './midstream.js';
30
+ import { MidstreamGuard, guardFor, loopbackBaseFor, chaosCutFetch, chaosCutState, CONTINUATION_HEADER, CONTINUATION_OF_HEADER, MAX_CONTINUATION_DEPTH, continuationDepth, continuationOfRequest } from './midstream.js';
31
+ /**
32
+ * The continuation fields for a request's analytics row, from its guard.
33
+ * Only the client's own request (depth 0) carries them: a resume leg is a
34
+ * loopback request with a guard of its own, and counting its attempt too
35
+ * would show one dying client stream as two. The log line still carries
36
+ * every leg's outcome — that is where the hop-by-hop story is read.
37
+ */
38
+ function continuationOf(guard, depth) {
39
+ if (!guard || !guard.outcome || depth > 0)
40
+ return undefined;
41
+ return { outcome: guard.outcome, ...(guard.continuedBy ? { by: guard.continuedBy } : {}), partialChars: guard.partialChars };
42
+ }
31
43
  import { responsesRequestToAnthropic, unsupportedOnClaudeError, ResponsesRequestError, ResponsesOut, wrapResponsesClient } from './responses-inbound.js';
32
44
  import { isClaudeServableModel } from './claude-model.js';
33
45
  import { MODEL_UNROUTABLE } from './upstream-rejection.js';
@@ -3129,6 +3141,12 @@ export async function startProxy(opts = {}) {
3129
3141
  // never continued itself (MAX_CONTINUATION_DEPTH).
3130
3142
  const requestDepth = continuationDepth(req.headers[CONTINUATION_HEADER]);
3131
3143
  const isContinuation = requestDepth >= MAX_CONTINUATION_DEPTH;
3144
+ // A resume leg's log row points back at the request whose stream died
3145
+ // (its guard's number) and says how deep it sits, so the hop-by-hop
3146
+ // story is readable from the log alone. Empty on a client request.
3147
+ const continuationLeg = requestDepth > 0
3148
+ ? { continuation_depth: requestDepth, continuation_of: continuationOfRequest(req.headers[CONTINUATION_OF_HEADER]) }
3149
+ : {};
3132
3150
  /**
3133
3151
  * First hop: the SAME model again, through the front door. The pool
3134
3152
  * picks a seat (sticky binding keeps the prompt cache warm), and if the
@@ -3650,6 +3668,7 @@ export async function startProxy(opts = {}) {
3650
3668
  // overage guard (#288) leaves it alone.
3651
3669
  claim: CODEX_CLAIM, util5h: 0, util7d: 0, overageUtil: 0,
3652
3670
  latencyMs: o.latencyMs, status: o.status, isStream: o.stream, isOpenAI,
3671
+ continuation: continuationOf(codexGuard, requestDepth),
3653
3672
  });
3654
3673
  writeLogLine(logFileStream, {
3655
3674
  ts: new Date().toISOString(), req: codexReq,
@@ -3657,6 +3676,8 @@ export async function startProxy(opts = {}) {
3657
3676
  status: o.status, latency_ms: o.latencyMs, in_tokens: o.inputTokens, out_tokens: o.outputTokens,
3658
3677
  cache_read: o.cacheReadTokens, cache_create: o.cacheCreateTokens,
3659
3678
  claim: CODEX_CLAIM, bucket: 'subscription', account: o.alias, consumer, stream: o.stream,
3679
+ ...(codexGuard?.outcome ? { continued: codexGuard.outcome, continued_by: codexGuard.continuedBy ?? undefined, continued_after: codexGuard.partialChars } : {}),
3680
+ ...continuationLeg,
3660
3681
  });
3661
3682
  if (verbose)
3662
3683
  console.log(formatUsageLogLine(codexReq, {
@@ -5182,6 +5203,7 @@ export async function startProxy(opts = {}) {
5182
5203
  thinkingTokens: Math.round(streamThinkingChars / 4),
5183
5204
  claim: rl.claim, util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
5184
5205
  latencyMs: Date.now() - startTime, status: upstream.status, isStream: true, isOpenAI,
5206
+ continuation: continuationOf(guard, requestDepth),
5185
5207
  });
5186
5208
  }
5187
5209
  writeLogLine(logFileStream, {
@@ -5198,6 +5220,8 @@ export async function startProxy(opts = {}) {
5198
5220
  client: detectedClientForLog,
5199
5221
  preserve_tools: preserveToolsEffective,
5200
5222
  stream: true,
5223
+ ...(guard?.outcome ? { continued: guard.outcome, continued_by: guard.continuedBy ?? undefined, continued_after: guard.partialChars } : {}),
5224
+ ...continuationLeg,
5201
5225
  });
5202
5226
  if (verbose)
5203
5227
  console.log(formatUsageLogLine(requestCount, {
@@ -161,6 +161,32 @@ its own answer; the other subscription takes over only when that model cannot
161
161
  serve the resume. dario warns loudly at startup while the tap is set; it is a
162
162
  demo and test affordance, never a default.
163
163
 
164
+ ## Seeing it after the fact
165
+
166
+ A continuation leaves three traces. The SSE comment on the wire
167
+ (`: dario continuation gpt-5.6-terra (codex live) after 1204 chars`) is the
168
+ one a raw capture shows; every SSE parser ignores it. The request log
169
+ (`--log-file`) tells the hop-by-hop story: the row of a request whose stream
170
+ died carries `continued` (`continued`, `continued-unfinished`,
171
+ `resume-failed`, `no-target`), `continued_by` (the leg that served the rest)
172
+ and `continued_after` (characters the client already had); every resume leg
173
+ has a row of its own with `continuation_depth` (1, or 2 for the resume of a
174
+ resume) and `continuation_of`, the number of the request it resumed — and,
175
+ when it died too, its own `continued_*` fields. A two-hop resume is three
176
+ rows that point at each other. `/analytics` tallies per window under
177
+ `continuations`: `attempted`, split into `finished`, `unfinished`, `failed`
178
+ and `noTarget`. Only the client's own request counts there — a resume leg is
179
+ a loopback request with a guard of its own, and counting its attempt too
180
+ would show one dying stream as two. `dario usage` prints the tally as one
181
+ line when anything died:
182
+
183
+ ```
184
+ Continuations: 3 streams died mid-answer: 2 finished, 1 unfinished
185
+ ```
186
+
187
+ `noTarget` above zero is the line to act on: streams are dying and there is
188
+ no `--pool-fallback` entry for the other provider to finish them.
189
+
164
190
  ## How it was proven
165
191
 
166
192
  `test/midstream-continuation-wiring.mjs` runs a real proxy against a fake
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.6.0",
3
+ "version": "6.6.1",
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": {