@askalf/dario 6.17.2 → 6.18.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.
@@ -55,6 +55,23 @@ export interface RequestRecord {
55
55
  * reached an upstream.
56
56
  */
57
57
  timing?: RequestTiming;
58
+ /**
59
+ * What a Codex request shared with the previous one on its cache key
60
+ * (src/cache-trace.ts): the gap, whether the input extends the previous
61
+ * one, and whether the seat or the prefix changed. Absent off that path.
62
+ */
63
+ cache?: RequestCacheTrace;
64
+ }
65
+ export interface RequestCacheTrace {
66
+ /** The `prompt_cache_key` the request went out under: dario's derived key as is, a caller's own key as a digest. */
67
+ key: string;
68
+ items: number;
69
+ sinceMs: number | null;
70
+ prevItems: number | null;
71
+ extends: boolean | null;
72
+ divergedAt: number | null;
73
+ seatChanged: boolean | null;
74
+ prefixChanged: boolean | null;
58
75
  }
59
76
  export interface RequestContinuation {
60
77
  /** See midstream.ts ContinuationOutcome. */
@@ -0,0 +1,106 @@
1
+ /** One input item, as a short digest of its JSON form. */
2
+ export declare function itemHash(item: unknown): string;
3
+ /**
4
+ * The key as the trace records it. A key dario derived itself
5
+ * (codexPromptCacheKey) is already a digest and is kept as it went out, so a
6
+ * trace matches the request; a caller-supplied key can be anything (a session
7
+ * id, an email, a secret that happens to look like a derived key) and is
8
+ * recorded only as a digest, stable per key so a run of traces still groups
9
+ * by conversation. Only the caller knows which it is, so the shape of the key
10
+ * decides nothing.
11
+ */
12
+ export declare function traceKey(key: string, derived: boolean): string;
13
+ /**
14
+ * The stable front of a request: model, instructions and tool names, as a
15
+ * short digest. The same material the derived cache key hashes, kept
16
+ * separately so a changed system prompt between two turns of one
17
+ * conversation shows up as such.
18
+ */
19
+ export declare function prefixHash(body: {
20
+ model?: unknown;
21
+ instructions?: unknown;
22
+ tools?: unknown;
23
+ }): string;
24
+ export interface CacheTraceInput {
25
+ /** The `prompt_cache_key` the request goes out with. */
26
+ key: string;
27
+ /** The seat that serves it. */
28
+ alias: string;
29
+ /** `prefixHash()` of the outgoing body. */
30
+ prefix: string;
31
+ /** `itemHash()` of every input item, in order. */
32
+ items: readonly string[];
33
+ /** When it goes out, ms since the epoch. */
34
+ at: number;
35
+ /**
36
+ * Dispatch order, stamped by `peek()` when absent, so two requests that
37
+ * went out in the same ms still order by which went out first.
38
+ */
39
+ seq?: number;
40
+ }
41
+ export interface CacheTraceResult {
42
+ /** Input items in this request. */
43
+ items: number;
44
+ /** ms since the previous request on this key; null on the first. */
45
+ sinceMs: number | null;
46
+ /** Input items in the previous request on this key; null on the first. */
47
+ prevItems: number | null;
48
+ /**
49
+ * The previous request's input is a strict prefix of this one: every item
50
+ * equal, this one longer. The shape a cache hit needs. null on the first,
51
+ * and when this one is longer but the previous input ran past
52
+ * CACHE_TRACE_MAX_ITEMS, so its tail was not kept to compare.
53
+ */
54
+ extends: boolean | null;
55
+ /**
56
+ * Index of the first input item that differs from the previous request,
57
+ * when one does. null when the inputs agree as far as both go (the new one
58
+ * extends, equals or shortens the old one) and on the first request.
59
+ */
60
+ divergedAt: number | null;
61
+ /** The seat differs from the previous request's; null on the first. */
62
+ seatChanged: boolean | null;
63
+ /** Model, instructions or tool names differ from the previous request's; null on the first. */
64
+ prefixChanged: boolean | null;
65
+ }
66
+ export declare const CACHE_TRACE_MAX_KEYS = 1000;
67
+ /** Longer than the backend keeps a prefix, so a stale entry cannot look like a live conversation. */
68
+ export declare const CACHE_TRACE_TTL_MS: number;
69
+ /** Items remembered per key. A longer input is compared on its first this many items and its length. */
70
+ export declare const CACHE_TRACE_MAX_ITEMS = 2000;
71
+ export declare class CacheTrace {
72
+ private readonly seen;
73
+ private readonly maxKeys;
74
+ private readonly ttlMs;
75
+ private readonly now;
76
+ private dispatched;
77
+ constructor(opts?: {
78
+ maxKeys?: number;
79
+ ttlMs?: number;
80
+ now?: () => number;
81
+ });
82
+ /** Compare a request with the previous one on its key, then remember it. */
83
+ note(input: CacheTraceInput): CacheTraceResult;
84
+ /** Compare a request with the previous one on its key, remembering nothing but its dispatch order. */
85
+ peek(input: CacheTraceInput): CacheTraceResult;
86
+ /**
87
+ * Make a request the one the next request on its key compares with, unless
88
+ * one that went out after it is already remembered: concurrent requests are
89
+ * accepted in whatever order their upstream headers arrive, and the next
90
+ * request compares with the latest to go out.
91
+ */
92
+ remember(input: CacheTraceInput): void;
93
+ /** Keys remembered right now. */
94
+ size(): number;
95
+ /**
96
+ * Drop every expired entry, then, while over the cap, the entry that went
97
+ * out earliest. By `at` rather than Map order: a request accepted late can
98
+ * be older than entries inserted before it.
99
+ */
100
+ private sweep;
101
+ }
102
+ /** The one-line trace for stdout: `key=... since=3.2s items=23 extends=yes read=0/44120 (0%)`. `key` is the `traceKey()` form. */
103
+ export declare function formatCacheTraceLine(requestCount: number, key: string, t: CacheTraceResult, usage: {
104
+ inputTokens: number;
105
+ cacheReadTokens: number;
106
+ }, alias?: string): string;
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Cache trace: what a Codex request shares with the previous request on the
3
+ * same `prompt_cache_key`.
4
+ *
5
+ * The backend caches prompt prefixes and reports what it read back as
6
+ * `cached_tokens`, and dario already records that number. What the number
7
+ * cannot say is WHY a turn missed. The trace records three things that bear
8
+ * on a hit and are visible to dario before the request goes out. They narrow
9
+ * the cause down; they do not prove it:
10
+ *
11
+ * 1. time: a cache write is not readable at once, so a turn that follows
12
+ * the previous one closely may read nothing it wrote. The gap is
13
+ * measured here; when the backend's write became readable is not;
14
+ * 2. shape: a hit needs the previous input to be a strict prefix of this
15
+ * one, item for item, with the same front in place. `extends` compares
16
+ * the input items only; the model, instructions and tool names are
17
+ * compared apart as `prefixChanged`, and tool schemas not at all;
18
+ * 3. seat: the cache is scoped to the serving account, so a conversation
19
+ * that moved seats starts over.
20
+ *
21
+ * `note()` answers all three for a request and remembers it for the next one.
22
+ * `peek()` and `remember()` are its two halves, for a caller that only knows
23
+ * after the comparison whether the request was served. It keeps hashes only:
24
+ * no content, no token counts. Pure except for the map it keeps, with an
25
+ * injectable clock so the tests need no sleeping.
26
+ */
27
+ import { createHash } from 'node:crypto';
28
+ /** One input item, as a short digest of its JSON form. */
29
+ export function itemHash(item) {
30
+ return createHash('sha256').update(JSON.stringify(item) ?? 'undefined').digest('hex').slice(0, 12);
31
+ }
32
+ /**
33
+ * The key as the trace records it. A key dario derived itself
34
+ * (codexPromptCacheKey) is already a digest and is kept as it went out, so a
35
+ * trace matches the request; a caller-supplied key can be anything (a session
36
+ * id, an email, a secret that happens to look like a derived key) and is
37
+ * recorded only as a digest, stable per key so a run of traces still groups
38
+ * by conversation. Only the caller knows which it is, so the shape of the key
39
+ * decides nothing.
40
+ */
41
+ export function traceKey(key, derived) {
42
+ if (derived)
43
+ return key;
44
+ return `key-${createHash('sha256').update('trace\0').update(key).digest('hex').slice(0, 16)}`;
45
+ }
46
+ /**
47
+ * The stable front of a request: model, instructions and tool names, as a
48
+ * short digest. The same material the derived cache key hashes, kept
49
+ * separately so a changed system prompt between two turns of one
50
+ * conversation shows up as such.
51
+ */
52
+ export function prefixHash(body) {
53
+ const h = createHash('sha256');
54
+ h.update(String(body.model ?? '')).update('\0');
55
+ h.update(typeof body.instructions === 'string' ? body.instructions : '').update('\0');
56
+ const tools = Array.isArray(body.tools) ? body.tools : [];
57
+ for (const t of tools)
58
+ h.update(typeof t?.name === 'string' ? t.name : String(t?.type ?? '')).update('\0');
59
+ return h.digest('hex').slice(0, 12);
60
+ }
61
+ export const CACHE_TRACE_MAX_KEYS = 1000;
62
+ /** Longer than the backend keeps a prefix, so a stale entry cannot look like a live conversation. */
63
+ export const CACHE_TRACE_TTL_MS = 60 * 60 * 1000;
64
+ /** Items remembered per key. A longer input is compared on its first this many items and its length. */
65
+ export const CACHE_TRACE_MAX_ITEMS = 2000;
66
+ export class CacheTrace {
67
+ seen = new Map();
68
+ maxKeys;
69
+ ttlMs;
70
+ now;
71
+ dispatched = 0;
72
+ constructor(opts = {}) {
73
+ this.maxKeys = opts.maxKeys ?? CACHE_TRACE_MAX_KEYS;
74
+ this.ttlMs = opts.ttlMs ?? CACHE_TRACE_TTL_MS;
75
+ this.now = opts.now ?? Date.now;
76
+ }
77
+ /** Compare a request with the previous one on its key, then remember it. */
78
+ note(input) {
79
+ const result = this.peek(input);
80
+ this.remember(input);
81
+ return result;
82
+ }
83
+ /** Compare a request with the previous one on its key, remembering nothing but its dispatch order. */
84
+ peek(input) {
85
+ if (input.seq === undefined)
86
+ input.seq = ++this.dispatched;
87
+ const prev = this.seen.get(input.key);
88
+ const live = prev !== undefined && input.at - prev.at <= this.ttlMs;
89
+ return live
90
+ ? {
91
+ items: input.items.length,
92
+ sinceMs: Math.max(0, input.at - prev.at),
93
+ prevItems: prev.count,
94
+ ...compare(prev, kept(input.items), input.items.length),
95
+ seatChanged: prev.alias !== input.alias,
96
+ prefixChanged: prev.prefix !== input.prefix,
97
+ }
98
+ : { items: input.items.length, sinceMs: null, prevItems: null, extends: null, divergedAt: null, seatChanged: null, prefixChanged: null };
99
+ }
100
+ /**
101
+ * Make a request the one the next request on its key compares with, unless
102
+ * one that went out after it is already remembered: concurrent requests are
103
+ * accepted in whatever order their upstream headers arrive, and the next
104
+ * request compares with the latest to go out.
105
+ */
106
+ remember(input) {
107
+ const seq = input.seq ?? 0;
108
+ const prev = this.seen.get(input.key);
109
+ if (prev !== undefined && (prev.at > input.at || (prev.at === input.at && prev.seq > seq)))
110
+ return;
111
+ this.seen.delete(input.key);
112
+ this.seen.set(input.key, { alias: input.alias, prefix: input.prefix, items: kept(input.items), count: input.items.length, at: input.at, seq });
113
+ this.sweep(input.at);
114
+ }
115
+ /** Keys remembered right now. */
116
+ size() {
117
+ return this.seen.size;
118
+ }
119
+ /**
120
+ * Drop every expired entry, then, while over the cap, the entry that went
121
+ * out earliest. By `at` rather than Map order: a request accepted late can
122
+ * be older than entries inserted before it.
123
+ */
124
+ sweep(at) {
125
+ for (const [key, r] of this.seen) {
126
+ if (at - r.at > this.ttlMs)
127
+ this.seen.delete(key);
128
+ }
129
+ while (this.seen.size > this.maxKeys) {
130
+ let oldest;
131
+ let oldestAt = Infinity;
132
+ for (const [key, r] of this.seen)
133
+ if (r.at < oldestAt) {
134
+ oldest = key;
135
+ oldestAt = r.at;
136
+ }
137
+ if (oldest === undefined)
138
+ break;
139
+ this.seen.delete(oldest);
140
+ }
141
+ }
142
+ }
143
+ function kept(items) {
144
+ return items.length > CACHE_TRACE_MAX_ITEMS ? items.slice(0, CACHE_TRACE_MAX_ITEMS) : items;
145
+ }
146
+ function compare(prev, cur, curCount) {
147
+ const shared = Math.min(prev.items.length, cur.length);
148
+ for (let i = 0; i < shared; i++) {
149
+ if (prev.items[i] !== cur[i])
150
+ return { extends: false, divergedAt: i };
151
+ }
152
+ if (curCount <= prev.count)
153
+ return { extends: false, divergedAt: null };
154
+ // Longer than the previous input and equal as far as it was kept; past
155
+ // that, nothing was kept to say whether it still agrees.
156
+ return { extends: prev.items.length < prev.count ? null : true, divergedAt: null };
157
+ }
158
+ /** The one-line trace for stdout: `key=... since=3.2s items=23 extends=yes read=0/44120 (0%)`. `key` is the `traceKey()` form. */
159
+ export function formatCacheTraceLine(requestCount, key, t, usage, alias) {
160
+ const prompt = usage.inputTokens + usage.cacheReadTokens;
161
+ const pct = prompt > 0 ? Math.round((usage.cacheReadTokens / prompt) * 100) : 0;
162
+ const yesNo = (v) => (v === null ? '-' : v ? 'yes' : 'no');
163
+ const since = t.sinceMs === null ? 'first' : `${(t.sinceMs / 1000).toFixed(1)}s`;
164
+ const parts = [
165
+ `key=${key.slice(0, 16)}`,
166
+ alias ? `seat=${alias}` : '',
167
+ `since=${since}`,
168
+ `items=${t.items}${t.prevItems === null ? '' : `/${t.prevItems}`}`,
169
+ `extends=${yesNo(t.extends)}`,
170
+ t.divergedAt === null ? '' : `diverged_at=${t.divergedAt}`,
171
+ t.seatChanged ? 'seat_changed' : '',
172
+ t.prefixChanged ? 'prefix_changed' : '',
173
+ `read=${usage.cacheReadTokens}/${prompt} (${pct}%)`,
174
+ ].filter(Boolean);
175
+ return `[dario] #${requestCount} cache: ${parts.join(' ')}`;
176
+ }
package/dist/cli.js CHANGED
@@ -393,6 +393,9 @@ async function proxy() {
393
393
  || args.includes('--verbose=2')
394
394
  || process.env.DARIO_LOG_BODIES === '1';
395
395
  const verbose = verboseBodies || args.includes('--verbose') || args.includes('-v');
396
+ // --cache-trace / DARIO_CACHE_TRACE=1: one line per Codex request on what it
397
+ // shared with the previous request on its cache key (src/cache-trace.ts).
398
+ const cacheTrace = args.includes('--cache-trace') || process.env.DARIO_CACHE_TRACE === '1';
396
399
  const passthrough = args.includes('--passthrough') || args.includes('--thin');
397
400
  const preserveTools = args.includes('--preserve-tools') || args.includes('--keep-tools');
398
401
  const hybridTools = args.includes('--hybrid-tools') || args.includes('--context-inject');
@@ -792,7 +795,7 @@ async function proxy() {
792
795
  console.error(`[dario] Override (not recommended): pass --unsafe-no-auth if you have out-of-band network controls and accept the risk.`);
793
796
  process.exit(1);
794
797
  }
795
- await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, analyticsToken, 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, maxConcurrentPerConsumer, shutdownGraceMs, upstreamTimeoutMs, poolStrategy, poolHeadroomFloor, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, familyFallback, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger, keys, keysPath, keySockets: keySockets.length > 0 ? keySockets : undefined });
798
+ await startProxy({ port, host, verbose, verboseBodies, cacheTrace, model, fastModel, noClaudeAuth, analyticsToken, 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, maxConcurrentPerConsumer, shutdownGraceMs, upstreamTimeoutMs, poolStrategy, poolHeadroomFloor, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, familyFallback, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger, keys, keysPath, keySockets: keySockets.length > 0 ? keySockets : undefined });
796
799
  }
797
800
  /**
798
801
  * `dario keys` — named keys for a shared dario (v6.8, dario#1318). One
@@ -2387,6 +2390,10 @@ async function help() {
2387
2390
  --verbose, -v Log all requests
2388
2391
  --verbose=2, -vv Also dump redacted request bodies
2389
2392
  (env: DARIO_LOG_BODIES=1)
2393
+ --cache-trace One line per ChatGPT (codex) request on what it
2394
+ shared with the previous request on its cache
2395
+ key: the gap, whether the input extends it, a
2396
+ seat or prompt change (env: DARIO_CACHE_TRACE=1)
2390
2397
  --log-file=PATH Append one JSON-ND record per completed
2391
2398
  request to PATH. Useful for backgrounded
2392
2399
  proxies where stdout is unobserved (where
@@ -4,6 +4,7 @@ import { type ResponsesReasoningConfig, type ResponsesUsage } from './anthropic-
4
4
  import { type ModelResolver, type ClaudeTarget } from './claude-model.js';
5
5
  import { type EffortValue } from './effort.js';
6
6
  import type { MidstreamGuard } from './midstream.js';
7
+ import { type CacheTraceResult } from './cache-trace.js';
7
8
  export declare const CODEX_BACKEND_BASE_URL: string;
8
9
  /** The usage endpoint the Codex CLI's /status reads, beside the Responses base. */
9
10
  export declare const CODEX_USAGE_URL: string;
@@ -52,7 +53,14 @@ export interface CodexForwardOutcome {
52
53
  stream: boolean;
53
54
  model: string;
54
55
  alias: string;
56
+ /** What the request shared with the previous one on its cache key (src/cache-trace.ts). Absent when no key went out. */
57
+ cache?: CodexCacheTrace;
55
58
  }
59
+ /** The cache trace of one request, with the key it went out under (`traceKey()`: a caller's own key only as a digest). */
60
+ export interface CodexCacheTrace extends CacheTraceResult {
61
+ key: string;
62
+ }
63
+ export declare function _resetCacheTraceForTest(): void;
56
64
  /** Why a forward DECLINED, for the caller's cool-down bookkeeping. `status` is
57
65
  * the upstream status, or 0 when the request never got one (transport). */
58
66
  export interface CodexDecline {
@@ -274,7 +282,38 @@ export declare function createResponsesTranslator(model: string): {
274
282
  export declare const CODEX_SUPPORTED_FIELDS: readonly string[];
275
283
  /** Drop every field this backend does not accept. Pure; exported for tests. */
276
284
  export declare function toCodexSupportedBody(body: Record<string, unknown>): Record<string, unknown>;
277
- export declare function buildCodexHeaders(creds: CodexAccountCredentials): Record<string, string>;
285
+ /** What a request adds to the seat's headers: its conversation and its model. */
286
+ export interface CodexRequestHeaderInput {
287
+ /** The `prompt_cache_key` the body goes out with; empty when none. */
288
+ cacheKey?: string;
289
+ /** The model the body names; empty when none. */
290
+ model?: string;
291
+ }
292
+ /**
293
+ * A header value for a key the request supplies. Printable ASCII without
294
+ * spaces goes as it is (the Codex CLI's session ids are UUIDs); anything
295
+ * else is sent as a stable digest, so a key with a space, a quote or a
296
+ * non-ASCII character cannot break the request or smuggle a header.
297
+ */
298
+ export declare function sessionHeaderValue(key: string): string;
299
+ /**
300
+ * The headers of one request to the ChatGPT backend: the seat's identity and,
301
+ * when the request is known, its conversation and model.
302
+ *
303
+ * The backend's prompt cache lives on individual machines and a request is
304
+ * routed to one by its `session-id` header: the Codex CLI sends its session
305
+ * id there on every turn (codex-rs: "ChatGPT derives cache affinity from the
306
+ * Responses session-id header") with the same value as its `prompt_cache_key`
307
+ * and a `thread-id`, and `x-codex-routing-hint: model=<model>`. Without the
308
+ * header a conversation's turns scatter across machines and read a cached
309
+ * prefix only when one happens to land where an earlier turn wrote it: with
310
+ * turns a few seconds apart and every input a strict extension of the last,
311
+ * a review loop through dario read its prefix on 8 of 23 turns, and what it
312
+ * read was a snapshot several turns old. So the key goes out in the header
313
+ * too, and the raw `/v1/responses` path sends it for the Codex CLI, whose
314
+ * own headers dario does not forward.
315
+ */
316
+ export declare function buildCodexHeaders(creds: CodexAccountCredentials, request?: CodexRequestHeaderInput): Record<string, string>;
278
317
  /**
279
318
  * Serve a Responses-shape request (`POST /v1/responses`) from a stored Codex
280
319
  * account with NO translation: the ChatGPT backend speaks this shape natively,
@@ -31,6 +31,7 @@ import { BAKED_BASE_MODELS } from './model-catalog.js';
31
31
  import { parseRetryAfterMs } from './provider-cooldown.js';
32
32
  import { upstreamDeadline } from './upstream-deadline.js';
33
33
  import { noteCodexUsageHeaders, parseCodexUsageEndpoint, recordCodexUsage, codexUsageFor } from './codex-usage.js';
34
+ import { CacheTrace, itemHash, prefixHash, traceKey } from './cache-trace.js';
34
35
  export const CODEX_BACKEND_BASE_URL = process.env.DARIO_CODEX_BASE_URL || 'https://chatgpt.com/backend-api/codex';
35
36
  /** The usage endpoint the Codex CLI's /status reads, beside the Responses base. */
36
37
  export const CODEX_USAGE_URL = process.env.DARIO_CODEX_USAGE_URL || `${CODEX_BACKEND_BASE_URL.replace(/\/+$/, '').replace(/\/codex$/, '')}/wham/usage`;
@@ -130,6 +131,25 @@ export async function fetchCodexModels(creds, fetchImpl = fetch) {
130
131
  }
131
132
  return slugs;
132
133
  }
134
+ // One trace per process: the comparison is between consecutive requests on a
135
+ // key, whichever seat or client path they came through.
136
+ let cacheTrace = new CacheTrace();
137
+ export function _resetCacheTraceForTest() {
138
+ cacheTrace = new CacheTrace();
139
+ }
140
+ /**
141
+ * Make a request the one the next request on its key compares with. Called
142
+ * once a seat has accepted it: a declined attempt retried on a peer is the
143
+ * same request, and must not become its own predecessor.
144
+ */
145
+ function rememberTrace(traced) {
146
+ if (!traced)
147
+ return;
148
+ try {
149
+ cacheTrace.remember(traced);
150
+ }
151
+ catch { /* the trace is a reading, not a step of the request */ }
152
+ }
133
153
  /** The cached slug list for an alias WITHOUT fetching. For the admin surface:
134
154
  * a status read must never cost an upstream call or a token refresh. */
135
155
  export function peekCodexModelSlugs(alias) {
@@ -776,7 +796,35 @@ export function toCodexSupportedBody(body) {
776
796
  }
777
797
  return out;
778
798
  }
779
- export function buildCodexHeaders(creds) {
799
+ /**
800
+ * A header value for a key the request supplies. Printable ASCII without
801
+ * spaces goes as it is (the Codex CLI's session ids are UUIDs); anything
802
+ * else is sent as a stable digest, so a key with a space, a quote or a
803
+ * non-ASCII character cannot break the request or smuggle a header.
804
+ */
805
+ export function sessionHeaderValue(key) {
806
+ if (/^[\x21-\x7e]{1,256}$/.test(key))
807
+ return key;
808
+ return createHash('sha256').update(key).digest('hex').slice(0, 32);
809
+ }
810
+ /**
811
+ * The headers of one request to the ChatGPT backend: the seat's identity and,
812
+ * when the request is known, its conversation and model.
813
+ *
814
+ * The backend's prompt cache lives on individual machines and a request is
815
+ * routed to one by its `session-id` header: the Codex CLI sends its session
816
+ * id there on every turn (codex-rs: "ChatGPT derives cache affinity from the
817
+ * Responses session-id header") with the same value as its `prompt_cache_key`
818
+ * and a `thread-id`, and `x-codex-routing-hint: model=<model>`. Without the
819
+ * header a conversation's turns scatter across machines and read a cached
820
+ * prefix only when one happens to land where an earlier turn wrote it: with
821
+ * turns a few seconds apart and every input a strict extension of the last,
822
+ * a review loop through dario read its prefix on 8 of 23 turns, and what it
823
+ * read was a snapshot several turns old. So the key goes out in the header
824
+ * too, and the raw `/v1/responses` path sends it for the Codex CLI, whose
825
+ * own headers dario does not forward.
826
+ */
827
+ export function buildCodexHeaders(creds, request = {}) {
780
828
  const headers = {
781
829
  'Content-Type': 'application/json',
782
830
  'Accept': 'text/event-stream',
@@ -787,6 +835,13 @@ export function buildCodexHeaders(creds) {
787
835
  const accountId = extractChatGPTAccountId(creds.idToken);
788
836
  if (accountId)
789
837
  headers['ChatGPT-Account-ID'] = accountId;
838
+ if (request.cacheKey) {
839
+ const id = sessionHeaderValue(request.cacheKey);
840
+ headers['session-id'] = id;
841
+ headers['thread-id'] = id;
842
+ }
843
+ if (request.model && /^[\x21-\x7e]{1,128}$/.test(request.model))
844
+ headers['x-codex-routing-hint'] = `model=${request.model}`;
790
845
  return headers;
791
846
  }
792
847
  /**
@@ -884,13 +939,15 @@ timing) {
884
939
  });
885
940
  const model = String(body.model ?? '');
886
941
  let reported = false;
942
+ let cache;
943
+ let traced;
887
944
  const report = (status, usage) => {
888
945
  if (reported || !onDone)
889
946
  return;
890
947
  reported = true;
891
948
  try {
892
949
  onDone({ status, latencyMs: Date.now() - startedAt, upstreamTtfbMs: ttfbMs(), upstreamMs: upstreamMsNow(), inputTokens: usage?.input ?? 0, outputTokens: usage?.output ?? 0,
893
- cacheReadTokens: usage?.cacheRead ?? 0, cacheCreateTokens: usage?.cacheCreate ?? 0, stream: true, model, alias: creds.alias });
950
+ cacheReadTokens: usage?.cacheRead ?? 0, cacheCreateTokens: usage?.cacheCreate ?? 0, stream: true, model, alias: creds.alias, cache });
894
951
  }
895
952
  catch { /* never break a served request */ }
896
953
  };
@@ -905,6 +962,20 @@ timing) {
905
962
  // default to make its next turn's previous_response_id resolvable, and
906
963
  // Codex CLI sends false itself.
907
964
  const upstreamBody = { ...body, stream: true };
965
+ // The same trace as forwardToCodex, under the key the client sent (Codex CLI
966
+ // sends its session id on every request); none when it sent no key.
967
+ // Compared here, remembered only once the seat accepts the request.
968
+ try {
969
+ const key = typeof body.prompt_cache_key === 'string' ? body.prompt_cache_key : '';
970
+ if (key) {
971
+ // The Responses API also takes a bare string input: one item, so a
972
+ // changed string diverges like a changed message would.
973
+ const input = Array.isArray(body.input) ? body.input : typeof body.input === 'string' ? [body.input] : [];
974
+ traced = { key, alias: creds.alias, prefix: prefixHash(body), items: input.map(itemHash), at: Date.now() };
975
+ cache = { key: traceKey(key, false), ...cacheTrace.peek(traced) };
976
+ }
977
+ }
978
+ catch { /* the trace is a reading, not a step of the request */ }
908
979
  const target = `${CODEX_BACKEND_BASE_URL.replace(/\/$/, '')}/responses`;
909
980
  const abort = new AbortController();
910
981
  let clientGone = false;
@@ -922,13 +993,17 @@ timing) {
922
993
  if (verbose)
923
994
  console.log(`[dario] → codex backend (responses passthrough): ${target} (model: ${model})`);
924
995
  let activeCreds = creds;
925
- let upstream = await timedFetch(target, { method: 'POST', headers: buildCodexHeaders(activeCreds), body: JSON.stringify(upstreamBody), signal: abort.signal });
996
+ // The client's own key as the session: the Codex CLI's headers do not
997
+ // pass through dario, so its cache affinity is rebuilt here from the key
998
+ // it sends in the body.
999
+ const requestHeaders = { cacheKey: typeof body.prompt_cache_key === 'string' ? body.prompt_cache_key : '', model };
1000
+ let upstream = await timedFetch(target, { method: 'POST', headers: buildCodexHeaders(activeCreds, requestHeaders), body: JSON.stringify(upstreamBody), signal: abort.signal });
926
1001
  if (isCodexAuthFailure(upstream.status)) {
927
1002
  await upstream.text().catch(() => ''); // release the rejected response before retrying
928
1003
  const fresh = await refreshAfterCodexAuthFailure(activeCreds, verbose);
929
1004
  if (fresh) {
930
1005
  activeCreds = fresh;
931
- upstream = await timedFetch(target, { method: 'POST', headers: buildCodexHeaders(activeCreds), body: JSON.stringify(upstreamBody), signal: abort.signal });
1006
+ upstream = await timedFetch(target, { method: 'POST', headers: buildCodexHeaders(activeCreds, requestHeaders), body: JSON.stringify(upstreamBody), signal: abort.signal });
932
1007
  }
933
1008
  }
934
1009
  if (!upstream.ok || !upstream.body) {
@@ -963,6 +1038,7 @@ timing) {
963
1038
  report(clientGone ? 499 : upstream.status, null);
964
1039
  return true;
965
1040
  }
1041
+ rememberTrace(traced);
966
1042
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': corsOrigin, ...securityHeaders, ...splitHeaders() });
967
1043
  timeout.streaming();
968
1044
  const reader = upstream.body.getReader();
@@ -1118,6 +1194,8 @@ timing) {
1118
1194
  fetchStartedAt: fetchStartedAt || Date.now(), upstreamTtfbMs: ttfbMs(),
1119
1195
  });
1120
1196
  let reported = false;
1197
+ let cache;
1198
+ let traced;
1121
1199
  const report = (status, usage, stream, model) => {
1122
1200
  if (reported || !onDone)
1123
1201
  return;
@@ -1127,7 +1205,7 @@ timing) {
1127
1205
  status, latencyMs: Date.now() - startedAt, upstreamTtfbMs: ttfbMs(), upstreamMs: upstreamMsNow(),
1128
1206
  inputTokens: usage?.input ?? 0, outputTokens: usage?.output ?? 0,
1129
1207
  cacheReadTokens: usage?.cacheRead ?? 0, cacheCreateTokens: usage?.cacheCreate ?? 0,
1130
- stream, model, alias: creds.alias,
1208
+ stream, model, alias: creds.alias, cache,
1131
1209
  });
1132
1210
  }
1133
1211
  catch { /* a reporting failure must never break a served request */ }
@@ -1155,10 +1233,27 @@ timing) {
1155
1233
  const upstreamBody = isAnthropic
1156
1234
  ? { ...anthropicToResponsesRequest(parsed, model, effort ? { effort } : {}), stream: true }
1157
1235
  : chatCompletionsToResponses(parsed);
1236
+ const ownCacheKey = upstreamBody.prompt_cache_key;
1158
1237
  const scrubbed = toCodexSupportedBody({
1159
1238
  ...upstreamBody,
1160
1239
  prompt_cache_key: codexPromptCacheKey(shape, parsed, upstreamBody),
1161
1240
  });
1241
+ // What this request shares with the previous one on its key, for the
1242
+ // analytics row and the log line (src/cache-trace.ts). Hashes only, and a
1243
+ // failure here never costs the request. Compared here, remembered only once
1244
+ // the seat accepts the request.
1245
+ try {
1246
+ const key = String(scrubbed.prompt_cache_key ?? '');
1247
+ if (key) {
1248
+ const input = Array.isArray(scrubbed.input) ? scrubbed.input : [];
1249
+ // Kept as is only when dario derived it; a client's own key is digested
1250
+ // whatever it looks like.
1251
+ const derived = !(typeof ownCacheKey === 'string' && ownCacheKey.length > 0);
1252
+ traced = { key, alias: creds.alias, prefix: prefixHash(scrubbed), items: input.map(itemHash), at: Date.now() };
1253
+ cache = { key: traceKey(key, derived), ...cacheTrace.peek(traced) };
1254
+ }
1255
+ }
1256
+ catch { /* the trace is a reading, not a step of the request */ }
1162
1257
  const target = `${CODEX_BACKEND_BASE_URL.replace(/\/$/, '')}/responses`;
1163
1258
  const abort = new AbortController();
1164
1259
  // Once the client is gone there is nobody left to write to, but the upstream
@@ -1207,9 +1302,12 @@ timing) {
1207
1302
  if (verbose)
1208
1303
  console.log(`[dario] → codex backend: ${target} (model: ${model})`);
1209
1304
  let activeCreds = creds;
1305
+ // The key the body goes out with, derived or the client's own, is the
1306
+ // session the backend routes the conversation by.
1307
+ const requestHeaders = { cacheKey: String(scrubbed.prompt_cache_key ?? ''), model: String(scrubbed.model ?? model) };
1210
1308
  let upstream = await timedFetch(target, {
1211
1309
  method: 'POST',
1212
- headers: buildCodexHeaders(activeCreds),
1310
+ headers: buildCodexHeaders(activeCreds, requestHeaders),
1213
1311
  body: JSON.stringify(scrubbed),
1214
1312
  signal: abort.signal,
1215
1313
  });
@@ -1222,7 +1320,7 @@ timing) {
1222
1320
  activeCreds = fresh;
1223
1321
  upstream = await timedFetch(target, {
1224
1322
  method: 'POST',
1225
- headers: buildCodexHeaders(activeCreds),
1323
+ headers: buildCodexHeaders(activeCreds, requestHeaders),
1226
1324
  body: JSON.stringify(scrubbed),
1227
1325
  signal: abort.signal,
1228
1326
  });
@@ -1277,6 +1375,7 @@ timing) {
1277
1375
  report(upstream.status, null, clientWantsStream, model);
1278
1376
  return true;
1279
1377
  }
1378
+ rememberTrace(traced);
1280
1379
  // OpenAI shape: one stateful line-in/line-out translator (unchanged).
1281
1380
  // Anthropic shape: parse the typed Responses event stream, then map events
1282
1381
  // to Anthropic SSE, keeping the terminal `response` for the collapse.
package/dist/proxy.d.ts CHANGED
@@ -4,6 +4,7 @@ import { getAccessToken, getStatus } from './oauth.js';
4
4
  import { type EffortValue } from './cc-template.js';
5
5
  import { type KeySocket } from './keys.js';
6
6
  import { type BackendCredentials } from './openai-backend.js';
7
+ import { type CodexCacheTrace } from './codex-backend.js';
7
8
  /**
8
9
  * Assemble the `x-anthropic-billing-header` system-block text, following
9
10
  * Claude Code's format:
@@ -358,6 +359,7 @@ interface ProxyOptions {
358
359
  host?: string;
359
360
  verbose?: boolean;
360
361
  verboseBodies?: boolean;
362
+ cacheTrace?: boolean;
361
363
  model?: string;
362
364
  fastModel?: string;
363
365
  noClaudeAuth?: boolean;
@@ -743,7 +745,18 @@ export interface ProxyLogEntry {
743
745
  reject?: string;
744
746
  error?: string;
745
747
  event?: string;
748
+ /** The cache trace of a Codex request (src/cache-trace.ts): the key it went out under and what it shared with the previous request on that key. */
749
+ cache_key?: string;
750
+ cache_items?: number;
751
+ cache_since_ms?: number | null;
752
+ cache_prev_items?: number | null;
753
+ cache_extends?: boolean | null;
754
+ cache_diverged_at?: number | null;
755
+ cache_seat_changed?: boolean | null;
756
+ cache_prefix_changed?: boolean | null;
746
757
  }
758
+ /** The log fields of a cache trace; nothing when the request carried none. */
759
+ export declare function cacheLogFields(c: CodexCacheTrace | undefined): Partial<ProxyLogEntry>;
747
760
  /**
748
761
  * Append a JSON-ND line to the proxy log file. No-op when stream is
749
762
  * null (logFile not configured). Errors are swallowed — log writes
package/dist/proxy.js CHANGED
@@ -33,6 +33,7 @@ import { handleAdminRequest } from './admin-api.js';
33
33
  import { createTokenBucket } from './rate-limit.js';
34
34
  import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI, isMiniMaxBackend, MINIMAX_MODELS } from './openai-backend.js';
35
35
  import { forwardToCodex, forwardResponsesToCodex, getCodexModelSlugs, peekCodexModelSlugs, isCodexModel, pickCodexFallback, pickClaudeTarget, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
36
+ import { formatCacheTraceLine } from './cache-trace.js';
36
37
  import { effortForCodex } from './effort.js';
37
38
  import { MidstreamGuard, guardFor, loopbackBaseFor, chaosCutFetch, chaosCutState, CONTINUATION_HEADER, CONTINUATION_OF_HEADER, MAX_CONTINUATION_DEPTH, continuationDepth, continuationOfRequest } from './midstream.js';
38
39
  /**
@@ -1075,6 +1076,15 @@ export const OPENAI_MODELS_LIST = buildOpenAIModelsList(withLongContextVariants(
1075
1076
  export function requiresClaudeLogin(poolSize, adminEnabled, hasUpstreamApiKey, noClaudeAuth, hasCodexAccount = false) {
1076
1077
  return poolSize === 0 && !adminEnabled && !hasUpstreamApiKey && !noClaudeAuth && !hasCodexAccount;
1077
1078
  }
1079
+ /** The log fields of a cache trace; nothing when the request carried none. */
1080
+ export function cacheLogFields(c) {
1081
+ if (!c)
1082
+ return {};
1083
+ return {
1084
+ cache_key: c.key, cache_items: c.items, cache_since_ms: c.sinceMs, cache_prev_items: c.prevItems,
1085
+ cache_extends: c.extends, cache_diverged_at: c.divergedAt, cache_seat_changed: c.seatChanged, cache_prefix_changed: c.prefixChanged,
1086
+ };
1087
+ }
1078
1088
  /**
1079
1089
  * Append a JSON-ND line to the proxy log file. No-op when stream is
1080
1090
  * null (logFile not configured). Errors are swallowed — log writes
@@ -1418,6 +1428,11 @@ export async function startProxy(opts = {}) {
1418
1428
  // -v stays quiet because bodies can carry file content and tool
1419
1429
  // output. Reported in dario#40 by @ringge.
1420
1430
  const verboseBodies = Boolean(opts.verboseBodies) || process.env.DARIO_LOG_BODIES === '1';
1431
+ // Cache trace line: --cache-trace / DARIO_CACHE_TRACE=1, and under -v. One
1432
+ // line per Codex request saying what it shared with the previous request on
1433
+ // its cache key (src/cache-trace.ts), so a low cache share can be read as
1434
+ // a timing, a shape or a seat problem instead of a number.
1435
+ const cacheTraceLines = Boolean(opts.cacheTrace) || process.env.DARIO_CACHE_TRACE === '1' || verbose;
1421
1436
  // Operator-declared beta passthrough set. Sourced from CLI flag or env;
1422
1437
  // both are CSV strings of beta-flag names. Trimmed, deduped, empty
1423
1438
  // entries dropped. Stays a Set for fast membership checks in the per-
@@ -2455,6 +2470,60 @@ export async function startProxy(opts = {}) {
2455
2470
  providerCooldowns.note('codex', d.retryAfterMs);
2456
2471
  }).catch(() => { });
2457
2472
  };
2473
+ /**
2474
+ * The /analytics row, request-log line and usage lines of one codex request.
2475
+ *
2476
+ * ONE function for BOTH routes that reach a seat: the codex route (a client
2477
+ * asking for a GPT model) and the pool-exhausted fallback below. The fallback
2478
+ * route passed no reporter until 6.18.1, so a request it served left no row
2479
+ * anywhere: /analytics, the request log and the ledger all showed a drained
2480
+ * Claude pool and nothing of the subscription that answered in its place.
2481
+ * The seat's served count and its decline state move here with the row, so a
2482
+ * route cannot have one without the others.
2483
+ */
2484
+ const reportCodexOutcome = (o, ctx) => {
2485
+ codexRequestCounts.set(o.alias, (codexRequestCounts.get(o.alias) ?? 0) + 1);
2486
+ // A seat that actually SERVED is not rate-limited. Keyed on a 2xx, never on
2487
+ // forwardToCodex returning true: that means "I wrote a response", which is
2488
+ // equally true when it wrote the upstream 429.
2489
+ if (o.status >= 200 && o.status < 300)
2490
+ clearCodexDecline(o.alias);
2491
+ analytics.record({
2492
+ timestamp: Date.now(),
2493
+ consumer: ctx.consumer,
2494
+ account: o.alias,
2495
+ model: o.model || ctx.model || 'codex',
2496
+ inputTokens: o.inputTokens, outputTokens: o.outputTokens,
2497
+ // Anthropic convention, like every other row: inputTokens is
2498
+ // net of the cached prefix, which sits in cacheReadTokens.
2499
+ cacheReadTokens: o.cacheReadTokens, cacheCreateTokens: o.cacheCreateTokens, thinkingTokens: 0,
2500
+ // No Anthropic rate-limit headers on this path; the claim
2501
+ // names the engine and is subscription billing, so the
2502
+ // overage guard (#288) leaves it alone.
2503
+ claim: CODEX_CLAIM, util5h: 0, util7d: 0, overageUtil: 0,
2504
+ latencyMs: o.latencyMs, status: o.status, isStream: o.stream, isOpenAI: ctx.isOpenAI,
2505
+ continuation: continuationOf(ctx.codexGuard, ctx.requestDepth),
2506
+ timing: { queueMs: ctx.queueMs, pacingMs: 0, upstreamTtfbMs: o.upstreamTtfbMs, upstreamMs: o.upstreamMs, totalMs: Math.max(0, Date.now() - ctx.arrivedAt) },
2507
+ cache: o.cache,
2508
+ });
2509
+ writeLogLine(logFileStream, {
2510
+ ts: new Date().toISOString(), req: ctx.codexReq,
2511
+ method: ctx.method, path: ctx.urlPath, model: o.model || ctx.model || undefined,
2512
+ status: o.status, latency_ms: o.latencyMs, in_tokens: o.inputTokens, out_tokens: o.outputTokens,
2513
+ cache_read: o.cacheReadTokens, cache_create: o.cacheCreateTokens,
2514
+ claim: CODEX_CLAIM, bucket: 'subscription', account: o.alias, consumer: ctx.consumer, stream: o.stream,
2515
+ ...(ctx.codexGuard?.outcome ? { continued: ctx.codexGuard.outcome, continued_by: ctx.codexGuard.continuedBy ?? undefined, continued_after: ctx.codexGuard.partialChars } : {}),
2516
+ ...ctx.continuationLeg,
2517
+ ...cacheLogFields(o.cache),
2518
+ });
2519
+ if (verbose)
2520
+ console.log(formatUsageLogLine(ctx.codexReq, {
2521
+ inputTokens: o.inputTokens, outputTokens: o.outputTokens,
2522
+ cacheReadTokens: o.cacheReadTokens, cacheCreateTokens: o.cacheCreateTokens,
2523
+ }, ctx.consumer));
2524
+ if (cacheTraceLines && o.cache)
2525
+ console.log(formatCacheTraceLine(ctx.codexReq, o.cache.key, o.cache, o, o.alias));
2526
+ };
2458
2527
  /**
2459
2528
  * Serve a pool-exhausted request from the ChatGPT subscription (v6.0.0).
2460
2529
  *
@@ -2473,7 +2542,13 @@ export async function startProxy(opts = {}) {
2473
2542
  */
2474
2543
  const tryCodexPoolFallback = async (req, res, body, fallbackModels, shape, why, attempted,
2475
2544
  /** The request's queue wait and arrival, so the codex response carries the timing headers too. */
2476
- timing) => {
2545
+ timing,
2546
+ /**
2547
+ * Files the request the seat answered (reportCodexOutcome), under the
2548
+ * request number its console line carried. Required: a route that reaches
2549
+ * a seat without it serves GPT that nothing counts.
2550
+ */
2551
+ onDone) => {
2477
2552
  if (fallbackModels.length === 0)
2478
2553
  return false;
2479
2554
  // Never a second codex attempt in the same request, and never one while it
@@ -2556,13 +2631,18 @@ export async function startProxy(opts = {}) {
2556
2631
  const fallbackBody = buildPoolFallbackBody(body, fallbackModel);
2557
2632
  if (!fallbackBody)
2558
2633
  return false;
2559
- console.log(`[dario] #${requestCount} ${why} → codex account ${seat.alias} as ${fallbackModel}`);
2634
+ const reqNo = requestCount;
2635
+ console.log(`[dario] #${reqNo} ${why} → codex account ${seat.alias} as ${fallbackModel}`);
2560
2636
  requestCount++;
2561
2637
  // Marked only once an attempt is actually being made. Marking it before
2562
2638
  // the guards above would tell the rest of the request that codex had been
2563
2639
  // tried when it had not, suppressing a later legitimate attempt.
2564
2640
  attempted.add('codex');
2565
- served = await forwardToCodex(req, res, fallbackBody, seat, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': fallbackModel }, upstreamTimeoutMs, verbose, shape, fetch, hasNextOption || peer !== null, undefined, codexOnDecline,
2641
+ served = await forwardToCodex(req, res, fallbackBody, seat, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': fallbackModel }, upstreamTimeoutMs, verbose, shape, fetch, hasNextOption || peer !== null,
2642
+ // The row files under the model the seat served as; the substituted
2643
+ // one when the seat named none. The client's own model is the Claude
2644
+ // one, which this request never reached.
2645
+ (o) => onDone({ ...o, model: o.model || fallbackModel }, reqNo), codexOnDecline,
2566
2646
  // The mirror of the Claude side (dario#1161): an operator who writes
2567
2647
  // `--pool-fallback=gpt-5.6-terra:high` is choosing the effort the
2568
2648
  // failover runs at, so the entry's own suffix reaches the request rather
@@ -2604,6 +2684,8 @@ export async function startProxy(opts = {}) {
2604
2684
  * this by construction, not by remembering to copy six lines correctly.
2605
2685
  */
2606
2686
  const attemptPoolFallbackOn429 = async (req, res, body, isOpenAI, attempted, timing,
2687
+ /** Files a request the subscription served; see tryCodexPoolFallback. */
2688
+ onDone,
2607
2689
  /**
2608
2690
  * Re-arm THIS request on the credits rung (credits-rung.ts): returns true
2609
2691
  * once the request is re-pointed at the key, and the caller re-enters its
@@ -2611,7 +2693,7 @@ export async function startProxy(opts = {}) {
2611
2693
  */
2612
2694
  armCredits) => {
2613
2695
  const chain = selectPoolFallbackForBody(body);
2614
- if (await tryCodexPoolFallback(req, res, body, chain, isOpenAI ? 'openai' : 'anthropic', 'pool exhausted mid-flight (429, no peer)', attempted, timing)) {
2696
+ if (await tryCodexPoolFallback(req, res, body, chain, isOpenAI ? 'openai' : 'anthropic', 'pool exhausted mid-flight (429, no peer)', attempted, timing, onDone)) {
2615
2697
  return 'served';
2616
2698
  }
2617
2699
  // The metered rungs, in the chain's own order. The credits rung is never
@@ -3693,6 +3775,14 @@ export async function startProxy(opts = {}) {
3693
3775
  const continuationLeg = requestDepth > 0
3694
3776
  ? { continuation_depth: requestDepth, continuation_of: continuationOfRequest(req.headers[CONTINUATION_OF_HEADER]) }
3695
3777
  : {};
3778
+ // The row of a request the pool-exhausted fallback handed to the
3779
+ // subscription, under the number the fallback's console line carried.
3780
+ // No model of its own to file under (the client asked for a Claude one)
3781
+ // and no midstream guard: that route does not resume a dead stream.
3782
+ const reportFallbackCodex = (o, reqNo) => reportCodexOutcome(o, {
3783
+ consumer, codexReq: reqNo, method: req.method ?? '', urlPath, model: '', isOpenAI,
3784
+ queueMs, arrivedAt, requestDepth, codexGuard: null, continuationLeg,
3785
+ });
3696
3786
  /**
3697
3787
  * First hop: the SAME model again, through the front door. The pool
3698
3788
  * picks a seat (sticky binding keeps the prompt cache warm), and if the
@@ -4365,51 +4455,18 @@ export async function startProxy(opts = {}) {
4365
4455
  // Reporting is one function for BOTH codex shapes below: the Responses
4366
4456
  // passthrough and the translated Messages path record the same row, so a
4367
4457
  // GPT request looks the same in /analytics whichever shape asked for it.
4458
+ // The row itself is reportCodexOutcome, shared with the pool-exhausted
4459
+ // fallback route.
4368
4460
  //
4369
4461
  // Before this hook a codex request left no trace: nothing in /analytics,
4370
4462
  // nothing in the request log, no per-account count. The dock (and anyone
4371
4463
  // reading /analytics) saw a proxy that served GPT all day and reported
4372
4464
  // zero of it. A decline (the request handed to the Claude pool) reports
4373
4465
  // nothing here; the Claude path records what it then serves.
4374
- const codexOnDone = (o) => {
4375
- codexRequestCounts.set(o.alias, (codexRequestCounts.get(o.alias) ?? 0) + 1);
4376
- // A seat that actually SERVED is not rate-limited. Keyed on a 2xx,
4377
- // never on forwardToCodex returning true — that means "I wrote a
4378
- // response", which is equally true when it wrote the upstream 429.
4379
- if (o.status >= 200 && o.status < 300)
4380
- clearCodexDecline(o.alias);
4381
- analytics.record({
4382
- timestamp: Date.now(),
4383
- consumer,
4384
- account: o.alias,
4385
- model: o.model || rawModel || 'codex',
4386
- inputTokens: o.inputTokens, outputTokens: o.outputTokens,
4387
- // Anthropic convention, like every other row: inputTokens is
4388
- // net of the cached prefix, which sits in cacheReadTokens.
4389
- cacheReadTokens: o.cacheReadTokens, cacheCreateTokens: o.cacheCreateTokens, thinkingTokens: 0,
4390
- // No Anthropic rate-limit headers on this path; the claim
4391
- // names the engine and is subscription billing, so the
4392
- // overage guard (#288) leaves it alone.
4393
- claim: CODEX_CLAIM, util5h: 0, util7d: 0, overageUtil: 0,
4394
- latencyMs: o.latencyMs, status: o.status, isStream: o.stream, isOpenAI,
4395
- continuation: continuationOf(codexGuard, requestDepth),
4396
- timing: { queueMs, pacingMs: 0, upstreamTtfbMs: o.upstreamTtfbMs, upstreamMs: o.upstreamMs, totalMs: Math.max(0, Date.now() - arrivedAt) },
4397
- });
4398
- writeLogLine(logFileStream, {
4399
- ts: new Date().toISOString(), req: codexReq,
4400
- method: req.method ?? '', path: urlPath, model: o.model || rawModel || undefined,
4401
- status: o.status, latency_ms: o.latencyMs, in_tokens: o.inputTokens, out_tokens: o.outputTokens,
4402
- cache_read: o.cacheReadTokens, cache_create: o.cacheCreateTokens,
4403
- claim: CODEX_CLAIM, bucket: 'subscription', account: o.alias, consumer, stream: o.stream,
4404
- ...(codexGuard?.outcome ? { continued: codexGuard.outcome, continued_by: codexGuard.continuedBy ?? undefined, continued_after: codexGuard.partialChars } : {}),
4405
- ...continuationLeg,
4406
- });
4407
- if (verbose)
4408
- console.log(formatUsageLogLine(codexReq, {
4409
- inputTokens: o.inputTokens, outputTokens: o.outputTokens,
4410
- cacheReadTokens: o.cacheReadTokens, cacheCreateTokens: o.cacheCreateTokens,
4411
- }, consumer));
4412
- };
4466
+ const codexOnDone = (o) => reportCodexOutcome(o, {
4467
+ consumer, codexReq, method: req.method ?? '', urlPath, model: rawModel, isOpenAI,
4468
+ queueMs, arrivedAt, requestDepth, codexGuard, continuationLeg,
4469
+ });
4413
4470
  // Mid-flight seat failover. A 429 lands BEFORE any body is written —
4414
4471
  // forwardToCodex only returns false on the decline path — so the same
4415
4472
  // request can be handed to a healthy peer instead of failing. Without
@@ -4656,7 +4713,7 @@ export async function startProxy(opts = {}) {
4656
4713
  if (!upstreamApiKey && !poolAccount) {
4657
4714
  attemptedProviders.add('claude');
4658
4715
  }
4659
- if (!upstreamApiKey && !poolAccount && !pinnedAccount && await tryCodexPoolFallback(req, res, body, selectPoolFallbackForBody(body), isOpenAI ? 'openai' : 'anthropic', 'pool exhausted', attemptedProviders, { queueMs, arrivedAt })) {
4716
+ if (!upstreamApiKey && !poolAccount && !pinnedAccount && await tryCodexPoolFallback(req, res, body, selectPoolFallbackForBody(body), isOpenAI ? 'openai' : 'anthropic', 'pool exhausted', attemptedProviders, { queueMs, arrivedAt }, reportFallbackCodex)) {
4660
4717
  return;
4661
4718
  }
4662
4719
  // `isOpenAI` is REQUIRED here and was not, before v6.0.0 — the selector's
@@ -5735,7 +5792,7 @@ export async function startProxy(opts = {}) {
5735
5792
  if (!creditsRung)
5736
5793
  providerCooldowns.note('claude', parseRetryAfterMs(upstream.headers.get('retry-after')));
5737
5794
  if (!pinnedAccount) {
5738
- const outcome = await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders, { queueMs, arrivedAt }, rearmOnCredits);
5795
+ const outcome = await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders, { queueMs, arrivedAt }, reportFallbackCodex, rearmOnCredits);
5739
5796
  if (outcome === 'served')
5740
5797
  return;
5741
5798
  if (outcome === 'credits') {
@@ -5870,7 +5927,7 @@ export async function startProxy(opts = {}) {
5870
5927
  if (!creditsRung)
5871
5928
  providerCooldowns.note('claude', parseRetryAfterMs(upstream.headers.get('retry-after')));
5872
5929
  if (!pinnedAccount) {
5873
- const outcome = await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders, { queueMs, arrivedAt }, rearmOnCredits);
5930
+ const outcome = await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders, { queueMs, arrivedAt }, reportFallbackCodex, rearmOnCredits);
5874
5931
  if (outcome === 'served')
5875
5932
  return;
5876
5933
  if (outcome === 'credits') {
package/docs/analytics.md CHANGED
@@ -77,6 +77,26 @@ scrape_configs:
77
77
  authorization: { credentials: '<DARIO_ANALYTICS_TOKEN>' }
78
78
  ```
79
79
 
80
+ ## The cache trace
81
+
82
+ A ChatGPT (codex) request reports how much of its prompt the backend read from its cache, and that number alone cannot say why a turn missed. dario records next to it what it can see before the request goes out that bears on a hit (`src/cache-trace.ts`). These narrow a miss down to likely causes; they do not prove one:
83
+
84
+ | field | says | a miss reads as |
85
+ |---|---|---|
86
+ | `sinceMs` | ms since the previous request on the same `prompt_cache_key`; null on the first | a write is not readable at once, so a turn close behind the last may read nothing it wrote; the gap is measured, the backend's readiness is not |
87
+ | `items`, `prevItems`, `extends` | input items now and before, and whether the previous input is a strict prefix of this one, item for item; `extends` is null when the previous input ran past the first 2000 items, which are all that is kept; input items only, the front of the prompt is `prefixChanged` | a client that rewrites or reorders its history has no prefix to hit |
88
+ | `divergedAt` | the first input item that differs, when one does | which turn the rewrite happened at |
89
+ | `prefixChanged` | the model, system prompt or tool names changed between the two; tool schemas are not compared | the front of the prompt moved, so nothing behind it can match |
90
+ | `seatChanged` | a different account served this one | the cache is per account; a conversation that moved seats starts over |
91
+
92
+ The trace is hashes and counts only: no content leaves the request. It rides on every `/analytics` row of that path as `cache` (with the `key`: dario's own derived `dario-…` key as it went out, a key the client set itself only as a `key-…` digest of it; a `/v1/responses` request goes through as the client wrote it, so it is traced under its own key, as Codex CLI sends one on every request, and carries no trace when it sent none), on the `--log-file` record as `cache_key`, `cache_items`, `cache_since_ms`, `cache_prev_items`, `cache_extends`, `cache_diverged_at`, `cache_seat_changed`, `cache_prefix_changed`, and with `--cache-trace` / `DARIO_CACHE_TRACE=1` (or `-v`) as one stdout line per request:
93
+
94
+ ```
95
+ [dario] #412 cache: key=dario-3f9a2c7e1b0d44a6 seat=work since=3.1s items=23/21 extends=yes read=0/44120 (0%)
96
+ ```
97
+
98
+ Read a run of them by key. `extends=yes` with `read=0`, a short `since` and no `prefix_changed` or `seat_changed` points at the backend's write delay rather than the shape of the input, but only as the likely explanation: the trace measures the gap, not when the write became readable, and `extends` compares input items only, so a changed tool schema, which the trace does not compare, misses the same way. `extends=no` with a `diverged_at` names the item the client changed; `prefix_changed` is a moved model, system prompt or tool name in front of an input that may still extend; `seat_changed` is the pool moving a conversation. The entry for a key is kept for an hour, longer than the backend keeps a prefix, so a stale key never reads as a live conversation.
99
+
80
100
  ## The rings
81
101
 
82
102
  `dario usage --donut` and `/analytics/donuts.svg` render the ledger's lifetime spend as three rings: **by model**, **by key** (empty until a named key or `x-dario-consumer` has traffic), and **subscription vs metered**. Each ring keeps the top five and folds the rest into *other*. Shares are of API-equivalent plus metered spend, so a model that only ever ran on an API key still shows.
package/docs/commands.md CHANGED
@@ -39,9 +39,10 @@ This page is the per-flag reference. For environment variables grouped by task
39
39
  | `--host=<addr>` / `DARIO_HOST` | Bind address. Use `0.0.0.0` for LAN, or a specific IP (e.g. a Tailscale interface). When non-loopback, also set `DARIO_API_KEY`. | `127.0.0.1` |
40
40
  | `--verbose` / `-v` | Log every request (one line per request — method + path + billing bucket) | off |
41
41
  | `--verbose=2` / `-vv` / `DARIO_LOG_BODIES=1` | Also dump the outbound request body (redacted: bearer tokens, `sk-ant-*` keys, JWTs stripped; capped at 8KB). For wire-level client-compat debugging. | off |
42
+ | `--cache-trace` / `DARIO_CACHE_TRACE=1` | One line per ChatGPT (codex) request on what it shared with the previous request on its `prompt_cache_key`: the gap in seconds, the input item counts, whether the input extends the previous one item for item (the shape a cache hit needs) and where it diverged if not, a seat change, a model, system prompt or tool change, and the cached share that came back. Also on under `-v`. The same facts ride on the `--log-file` record (`cache_*`) and the `/analytics` row (`cache`) whether or not the line is on. See [Analytics](./analytics.md#the-cache-trace). | off |
42
43
  | `--key-socket=<path>=<key>` / `DARIO_KEY_SOCKETS` | Also listen on a unix socket bound to one named key: every request on it is that key's, with no secret sent or stored by the caller; the socket's directory and group decide who may connect. Repeatable. See [Key sockets](./keys.md#a-key-with-no-secret-key-sockets). | none |
43
44
  | `--no-keys` / `DARIO_KEYS=0`; `--keys-path=<file>` / `DARIO_KEYS_PATH` | Ignore named keys (`~/.dario/keys.json`) so only `DARIO_API_KEY` authenticates; or move the file. See [Named keys](./keys.md). (v6.8) | on, `~/.dario/keys.json` |
44
- | `--log-file=<path>` / `DARIO_LOG_FILE` | Append one JSON-ND record per completed request to PATH. Useful for backgrounded proxies where stdout is unobserved (where `--verbose` can't help). Field set: `ts`, `req`, `method`, `path`, `model`, `status`, `latency_ms`, `in_tokens`, `out_tokens`, `cache_read`, `cache_create`, `claim`, `bucket`, `account`, `client`, `preserve_tools`, `stream`, plus `reject` / `error` on failure paths. Secrets scrubbed via the same redactor that `--verbose-bodies` uses; no request bodies. | off |
45
+ | `--log-file=<path>` / `DARIO_LOG_FILE` | Append one JSON-ND record per completed request to PATH. Useful for backgrounded proxies where stdout is unobserved (where `--verbose` can't help). Field set: `ts`, `req`, `method`, `path`, `model`, `status`, `latency_ms`, `in_tokens`, `out_tokens`, `cache_read`, `cache_create`, `claim`, `bucket`, `account`, `client`, `preserve_tools`, `stream`, plus `reject` / `error` on failure paths, and on a ChatGPT (codex) request the cache trace `cache_key`, `cache_items`, `cache_since_ms`, `cache_prev_items`, `cache_extends`, `cache_diverged_at`, `cache_seat_changed`, `cache_prefix_changed`. Secrets scrubbed via the same redactor that `--verbose-bodies` uses; no request bodies. | off |
45
46
  | `--pool-fallback=<models>` / `DARIO_POOL_FALLBACK` / config `poolFallback.model` | Strictly opt-in. When every pool seat is drained or in auth cool-down (at selection, or mid-flight on a 429 with no peer left), serve the request as `<model>` from whichever provider can, instead of surfacing the 429/503. Accepts a **chain** — `gpt-5.6-sol,claude-sonnet-5` — read left to right, each provider taking the first entry it can serve. A Codex/ChatGPT subscription that lists the model is preferred (no per-token cost) and works on **both** wire shapes; otherwise a configured openai-compat backend, which is still OpenAI-path only (no Messages translation on that route). A chain also makes failover **symmetric**: a rate-limited or failing subscription hands the request back to the Claude pool. Only a 429/5xx fails over — a 400 surfaces, since a bad request would just reproduce itself elsewhere. Every substituted response carries `x-dario-pool-fallback: <model>` — never silent. Needs a Codex account (`dario add altman`) or a backend (`dario backend add …`); `dario doctor` reports it as INERT with neither. Empty pool still 503s (setup error, not traffic to re-bill). Empty flag value disables, overriding env + config. See [Pool-exhausted fallback](./multi-account-pool.md#pool-exhausted-fallback). | off |
46
47
  | `--family-fallback=<family=model,...>` / `DARIO_FAMILY_FALLBACK` / config `familyFallback` | Strictly opt-in. When a rate limit keeps every pool seat off a model family, serve that family's requests as `<model>` on the Claude pool, while some seat can serve the target's family. Written for Fable: its included credit runs out while the seat still has room for Opus, so `fable=claude-opus-5-5` keeps Fable traffic on the subscription instead of a local 429 until the credit resets. The target must be a `claude-*` model of another family; one step, never chained. Runs before `--pool-fallback`, so a pool with room for the target never leaves the Claude pool. Skipped in upstream API-key mode, under `--model`/`--fast-model` and for a seat-pinned request. A `[1m]` tag on the request carries over. Every switched response carries `x-dario-family-fallback: <requested model>`. Empty flag value disables, overriding env + config. See [Family fallback](./multi-account-pool.md#family-fallback). | off |
47
48
  | `--passthrough-betas=<csv>` / `DARIO_PASSTHROUGH_BETAS` | Beta flags ALWAYS forwarded upstream regardless of CC's captured set or the client's `anthropic-beta` header. Bypasses the billable-beta filter (so `extended-cache-ttl-*` survives if you opt in). Per-account rejection cache still applies — a pinned flag the upstream 400's gets dropped on retry rather than re-sent forever. Use when you know a beta works on your account but isn't in the captured template, or when client traffic should be force-augmented. Empty flag value (`--passthrough-betas=`) clears the env-default. | off |
package/docs/docker.md CHANGED
@@ -109,6 +109,7 @@ All flags have env-var equivalents. The image sets sensible container defaults:
109
109
  | `DARIO_CORS_ORIGIN` | unset | Override the default `http://localhost:<port>` allow-list |
110
110
  | `DARIO_LOG_FILE` | unset | Path inside the container (mount a volume) |
111
111
  | `DARIO_LOG_BODIES` | unset | `1` to log request/response bodies |
112
+ | `DARIO_CACHE_TRACE` | unset | `1` to log one cache-trace line per ChatGPT (codex) request, see [`docs/analytics.md`](./analytics.md#the-cache-trace) |
112
113
  | `DARIO_PASSTHROUGH_BETAS` | unset | `1` to forward `anthropic-beta` headers as-is |
113
114
  | `DARIO_CLAUDE_BIN` | unset | Path to a Claude Code binary (optional, for live template capture) |
114
115
  | `DARIO_NO_BUN` | unset | `1` to skip the Bun auto-relaunch (not recommended) |
@@ -136,6 +136,10 @@ Anthropic's token endpoint is the one mocked part, and only that — hammering t
136
136
 
137
137
  ## If you need true HA
138
138
 
139
- There is no shared-state mode today, and building one is a larger change than a lock: rate-limit snapshots and sticky bindings would both have to move out of process memory, which means every routing decision takes a network hop.
139
+ Everything above is as far as dario goes today: shared credentials through the lock, and shared rate-limit readings and sticky bindings through `--pool-shared-state`, both eventually consistent and both failing open. Three things are still per instance:
140
140
 
141
- If you want that, say so on [#993](https://github.com/askalf/dario/issues/993) with your deployment shape. Concrete requirements are considerably more useful than a general "make it HA" — the design depends heavily on whether you need precise accounting or merely approximate.
141
+ - **ChatGPT seats.** The Codex accounts have their own refresh flow and usage windows; neither the lock nor the shared state covers them.
142
+ - **The ledger.** Each instance writes its own file, so `dario usage` and `/analytics` show that instance's share of the spend.
143
+ - **The overage guard's halt**, which each instance tracks on its own.
144
+
145
+ A design where every routing decision reads Redis on the request path is not planned: it adds a network hop per request and turns the coordination point into a hard dependency, which is exactly what the fail-open rule avoids. Extending the report-and-pull model to the ChatGPT seats is the natural next step. If you need it, say so on [#1733](https://github.com/askalf/dario/issues/1733) with your deployment shape: replica count, Claude only or both plans, and whether accounting has to be exact or may lag a pull interval.
package/docs/two-plans.md CHANGED
@@ -26,9 +26,9 @@ curl localhost:3456/v1/messages -H 'content-type: application/json' \
26
26
 
27
27
  **Model names are discovered, not hardcoded.** The set a ChatGPT subscription may use is per-account and moves; dario asks the backend which models this account lists, caches the answer, and advertises them on `GET /v1/models`. Anything not on that list (`gpt-4o` and friends) still routes to a configured API-key backend as before. `codex:<model>` / `chatgpt:<model>` forces the route.
28
28
 
29
- Streaming, tool calls and tool-result round trips work on both shapes, and chat-shape `image_url` parts are carried as Responses `input_image` parts with `detail` preserved: dario translates chat/completions **or** Messages into the Responses API the subscription backend speaks, and translates the stream back into `chat.completion.chunk` or Anthropic message events. There is no `/v1/responses` inbound yet. The Codex backend does not accept every chat field, so `response_format`, `stop`, `n`, `logprobs`, `stream_options` and the sampling parameters `temperature`, `top_p`, `max_tokens`, `max_completion_tokens` are intentionally lossy; with `--verbose`, dario reports each field that does not reach Codex once per process. Codex accounts live in `~/.dario/codex-accounts/`, separate from the Claude pool.
29
+ Streaming, tool calls and tool-result round trips work on both shapes, and chat-shape `image_url` parts are carried as Responses `input_image` parts with `detail` preserved: dario translates chat/completions **or** Messages into the Responses API the subscription backend speaks, and translates the stream back into `chat.completion.chunk` or Anthropic message events. A `/v1/responses` request needs no translation: the backend speaks that shape, so the body goes through as the client wrote it (model resolved) and the stream comes back untouched, on the same seats and failover as the other shapes (see [Codex CLI](./integrations/codex-cli.md)). The Codex backend does not accept every chat field, so `response_format`, `stop`, `n`, `logprobs`, `stream_options` and the sampling parameters `temperature`, `top_p`, `max_tokens`, `max_completion_tokens` are intentionally lossy; with `--verbose`, dario reports each field that does not reach Codex once per process. Codex accounts live in `~/.dario/codex-accounts/`, separate from the Claude pool.
30
30
 
31
- **Prompt caching:** the backend caches prompt prefixes of 1,024 tokens and up on its own; what dario adds is the `prompt_cache_key` that routes same-prefix requests to the cache that holds them, the way the Codex CLI does with its session id. A chat/completions client that sets its own key keeps it; an Anthropic-shape request gets one per Claude Code session (a hash of `metadata.user_id`, never the raw ids); anything else is keyed on its model, instructions and tool names, so repeated system prompts from any caller land together. Cached tokens come back as `prompt_tokens_details.cached_tokens` on chat/completions and as `cache_read_input_tokens` on `/v1/messages`, and show up in `/analytics` and the `-v` usage line like a Claude request's do.
31
+ **Prompt caching:** the backend caches prompt prefixes of 1,024 tokens and up on its own; what dario adds is the `prompt_cache_key` that routes same-prefix requests to the cache that holds them, the way the Codex CLI does with its session id. A chat/completions client that sets its own key keeps it; an Anthropic-shape request gets one per Claude Code session (a hash of `metadata.user_id`, never the raw ids); anything else is keyed on its model, instructions and tool names, so repeated system prompts from any caller land together. The key also goes out as the `session-id` header (with `thread-id` and `x-codex-routing-hint: model=<model>`, as the Codex CLI sends them): the backend's cache lives on individual machines and that header is what routes a conversation's next turn to the machine holding its prefix; the body key alone does not. Cached tokens come back as `prompt_tokens_details.cached_tokens` on chat/completions and as `cache_read_input_tokens` on `/v1/messages`, and show up in `/analytics` and the `-v` usage line like a Claude request's do. When the share is lower than the conversation shape would predict, the [cache trace](./analytics.md#the-cache-trace) shows what may explain it: turns close enough together that the write may not yet have been readable, a rewritten history, a changed system prompt or tool names, or a changed seat.
32
32
 
33
33
  ## Failover between subscriptions
34
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.17.2",
3
+ "version": "6.18.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, opt-in failover when one hits its limit, and a ledger of what each plan absorbed.",
5
5
  "type": "module",
6
6
  "bin": {