@askalf/dario 6.17.1 → 6.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_version": "2.1.311",
3
- "_captured": "2026-10-23T12:28:22.488Z",
3
+ "_captured": "2026-10-26T22:30:54.888Z",
4
4
  "_source": "bundled",
5
5
  "_schemaVersion": 3,
6
6
  "agent_identity": "You are a Claude agent, built on Anthropic's Claude Agent SDK.",
@@ -188,7 +188,7 @@
188
188
  },
189
189
  {
190
190
  "name": "Bash",
191
- "description": "Executes a bash command and returns its output.\n\n- Working directory persists between calls, but prefer absolute paths — `cd` in a compound command can trigger a permission prompt. Shell state (env vars, functions) does not persist; the shell is initialized from the user's profile.\n- IMPORTANT: Avoid using this tool to run `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user.\n- Command output is displayed to you, not reliably to the user.\n- `timeout` is in milliseconds: default 120000, max 600000 for a foreground command.\n- `run_in_background` runs the command detached: it keeps running across turns and re-invokes you when it exits. With it, `timeout` is how long the command may run in the background (default 600000, max 7200000); at that limit it is stopped and you are re-invoked. No `&` needed. Foreground `sleep` is blocked; use Monitor with an until-loop to wait on a condition.\n\n# Git\n- Interactive flags (`-i`, e.g. `git rebase -i`, `git add -i`) are not supported in this environment.\n- Use the `gh` CLI for GitHub operations (PRs, issues, API).\n- GitHub notifies the owner of any `@name` written in a PR, issue, or comment. Write a bare `@name` only for a real login you mean to notify. Put roles, placeholders, and code (`@human`, `@reviewer`, `@types/node`) in backticks or drop the `@`, even when copied from project files.\n- Commit or push only when the user asks. If on the default branch, branch first.\n- End git commit messages and PR bodies with the attribution lines given in the conversation's system-reminder, when one is present.",
191
+ "description": "Executes a bash command and returns its output.\n\n- `cd` carries to the next call only in the main session, if the command succeeds and ends inside the working directories. Outside them the shell returns to the start folder; otherwise it stays where it was. Prefer absolute paths — `cd` in a compound command can trigger a permission prompt. Shell state (env vars, functions) does not persist; the shell is initialized from the user's profile.\n- IMPORTANT: Avoid using this tool to run `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user.\n- Command output is displayed to you, not reliably to the user.\n- `timeout` is in milliseconds: default 120000, max 600000 for a foreground command.\n- `run_in_background` runs the command detached: it keeps running across turns and re-invokes you when it exits. With it, `timeout` is how long the command may run in the background (default 600000, max 7200000); at that limit it is stopped and you are re-invoked. No `&` needed. Foreground `sleep` is blocked; use Monitor with an until-loop to wait on a condition.\n\n# Git\n- Interactive flags (`-i`, e.g. `git rebase -i`, `git add -i`) are not supported in this environment.\n- Use the `gh` CLI for GitHub operations (PRs, issues, API).\n- GitHub notifies the owner of any `@name` written in a PR, issue, or comment. Write a bare `@name` only for a real login you mean to notify. Put roles, placeholders, and code (`@human`, `@reviewer`, `@types/node`) in backticks or drop the `@`, even when copied from project files.\n- Commit or push only when the user asks. If on the default branch, branch first.\n- End git commit messages and PR bodies with the attribution lines given in the conversation's system-reminder, when one is present.",
192
192
  "input_schema": {
193
193
  "$schema": "https://json-schema.org/draft/2020-12/schema",
194
194
  "type": "object",
@@ -1525,7 +1525,7 @@
1525
1525
  "accept-encoding",
1526
1526
  "content-length"
1527
1527
  ],
1528
- "anthropic_beta": "claude-code-20250219,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,mid-conversation-tool-changes-2026-07-01,inline-tools-2026-09-15,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,effort-2025-11-24",
1528
+ "anthropic_beta": "claude-code-20250219,interleaved-thinking-2025-05-14,context-management-2025-06-27,mid-conversation-tool-changes-2026-07-01,inline-tools-2026-09-15,advisor-tool-2026-03-01,thinking-token-count-2026-05-13,advanced-tool-use-2025-11-20,effort-2025-11-24",
1529
1529
  "header_values": {
1530
1530
  "accept": "application/json",
1531
1531
  "user-agent": "claude-cli/2.1.311 (external, sdk-cli)",
@@ -1533,7 +1533,7 @@
1533
1533
  "x-stainless-package-version": "0.128.0",
1534
1534
  "x-stainless-retry-count": "0",
1535
1535
  "x-stainless-runtime": "node",
1536
- "x-stainless-runtime-version": "v26.3.0",
1536
+ "x-stainless-runtime-version": "v26.12.1",
1537
1537
  "x-stainless-timeout": "600",
1538
1538
  "anthropic-dangerous-direct-browser-access": "true",
1539
1539
  "anthropic-version": "2023-06-01",
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 {
@@ -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) {
@@ -884,13 +904,15 @@ timing) {
884
904
  });
885
905
  const model = String(body.model ?? '');
886
906
  let reported = false;
907
+ let cache;
908
+ let traced;
887
909
  const report = (status, usage) => {
888
910
  if (reported || !onDone)
889
911
  return;
890
912
  reported = true;
891
913
  try {
892
914
  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 });
915
+ cacheReadTokens: usage?.cacheRead ?? 0, cacheCreateTokens: usage?.cacheCreate ?? 0, stream: true, model, alias: creds.alias, cache });
894
916
  }
895
917
  catch { /* never break a served request */ }
896
918
  };
@@ -905,6 +927,20 @@ timing) {
905
927
  // default to make its next turn's previous_response_id resolvable, and
906
928
  // Codex CLI sends false itself.
907
929
  const upstreamBody = { ...body, stream: true };
930
+ // The same trace as forwardToCodex, under the key the client sent (Codex CLI
931
+ // sends its session id on every request); none when it sent no key.
932
+ // Compared here, remembered only once the seat accepts the request.
933
+ try {
934
+ const key = typeof body.prompt_cache_key === 'string' ? body.prompt_cache_key : '';
935
+ if (key) {
936
+ // The Responses API also takes a bare string input: one item, so a
937
+ // changed string diverges like a changed message would.
938
+ const input = Array.isArray(body.input) ? body.input : typeof body.input === 'string' ? [body.input] : [];
939
+ traced = { key, alias: creds.alias, prefix: prefixHash(body), items: input.map(itemHash), at: Date.now() };
940
+ cache = { key: traceKey(key, false), ...cacheTrace.peek(traced) };
941
+ }
942
+ }
943
+ catch { /* the trace is a reading, not a step of the request */ }
908
944
  const target = `${CODEX_BACKEND_BASE_URL.replace(/\/$/, '')}/responses`;
909
945
  const abort = new AbortController();
910
946
  let clientGone = false;
@@ -963,6 +999,7 @@ timing) {
963
999
  report(clientGone ? 499 : upstream.status, null);
964
1000
  return true;
965
1001
  }
1002
+ rememberTrace(traced);
966
1003
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': corsOrigin, ...securityHeaders, ...splitHeaders() });
967
1004
  timeout.streaming();
968
1005
  const reader = upstream.body.getReader();
@@ -1118,6 +1155,8 @@ timing) {
1118
1155
  fetchStartedAt: fetchStartedAt || Date.now(), upstreamTtfbMs: ttfbMs(),
1119
1156
  });
1120
1157
  let reported = false;
1158
+ let cache;
1159
+ let traced;
1121
1160
  const report = (status, usage, stream, model) => {
1122
1161
  if (reported || !onDone)
1123
1162
  return;
@@ -1127,7 +1166,7 @@ timing) {
1127
1166
  status, latencyMs: Date.now() - startedAt, upstreamTtfbMs: ttfbMs(), upstreamMs: upstreamMsNow(),
1128
1167
  inputTokens: usage?.input ?? 0, outputTokens: usage?.output ?? 0,
1129
1168
  cacheReadTokens: usage?.cacheRead ?? 0, cacheCreateTokens: usage?.cacheCreate ?? 0,
1130
- stream, model, alias: creds.alias,
1169
+ stream, model, alias: creds.alias, cache,
1131
1170
  });
1132
1171
  }
1133
1172
  catch { /* a reporting failure must never break a served request */ }
@@ -1155,10 +1194,27 @@ timing) {
1155
1194
  const upstreamBody = isAnthropic
1156
1195
  ? { ...anthropicToResponsesRequest(parsed, model, effort ? { effort } : {}), stream: true }
1157
1196
  : chatCompletionsToResponses(parsed);
1197
+ const ownCacheKey = upstreamBody.prompt_cache_key;
1158
1198
  const scrubbed = toCodexSupportedBody({
1159
1199
  ...upstreamBody,
1160
1200
  prompt_cache_key: codexPromptCacheKey(shape, parsed, upstreamBody),
1161
1201
  });
1202
+ // What this request shares with the previous one on its key, for the
1203
+ // analytics row and the log line (src/cache-trace.ts). Hashes only, and a
1204
+ // failure here never costs the request. Compared here, remembered only once
1205
+ // the seat accepts the request.
1206
+ try {
1207
+ const key = String(scrubbed.prompt_cache_key ?? '');
1208
+ if (key) {
1209
+ const input = Array.isArray(scrubbed.input) ? scrubbed.input : [];
1210
+ // Kept as is only when dario derived it; a client's own key is digested
1211
+ // whatever it looks like.
1212
+ const derived = !(typeof ownCacheKey === 'string' && ownCacheKey.length > 0);
1213
+ traced = { key, alias: creds.alias, prefix: prefixHash(scrubbed), items: input.map(itemHash), at: Date.now() };
1214
+ cache = { key: traceKey(key, derived), ...cacheTrace.peek(traced) };
1215
+ }
1216
+ }
1217
+ catch { /* the trace is a reading, not a step of the request */ }
1162
1218
  const target = `${CODEX_BACKEND_BASE_URL.replace(/\/$/, '')}/responses`;
1163
1219
  const abort = new AbortController();
1164
1220
  // Once the client is gone there is nobody left to write to, but the upstream
@@ -1277,6 +1333,7 @@ timing) {
1277
1333
  report(upstream.status, null, clientWantsStream, model);
1278
1334
  return true;
1279
1335
  }
1336
+ rememberTrace(traced);
1280
1337
  // OpenAI shape: one stateful line-in/line-out translator (unchanged).
1281
1338
  // Anthropic shape: parse the typed Responses event stream, then map events
1282
1339
  // 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-
@@ -4394,6 +4409,7 @@ export async function startProxy(opts = {}) {
4394
4409
  latencyMs: o.latencyMs, status: o.status, isStream: o.stream, isOpenAI,
4395
4410
  continuation: continuationOf(codexGuard, requestDepth),
4396
4411
  timing: { queueMs, pacingMs: 0, upstreamTtfbMs: o.upstreamTtfbMs, upstreamMs: o.upstreamMs, totalMs: Math.max(0, Date.now() - arrivedAt) },
4412
+ cache: o.cache,
4397
4413
  });
4398
4414
  writeLogLine(logFileStream, {
4399
4415
  ts: new Date().toISOString(), req: codexReq,
@@ -4403,12 +4419,15 @@ export async function startProxy(opts = {}) {
4403
4419
  claim: CODEX_CLAIM, bucket: 'subscription', account: o.alias, consumer, stream: o.stream,
4404
4420
  ...(codexGuard?.outcome ? { continued: codexGuard.outcome, continued_by: codexGuard.continuedBy ?? undefined, continued_after: codexGuard.partialChars } : {}),
4405
4421
  ...continuationLeg,
4422
+ ...cacheLogFields(o.cache),
4406
4423
  });
4407
4424
  if (verbose)
4408
4425
  console.log(formatUsageLogLine(codexReq, {
4409
4426
  inputTokens: o.inputTokens, outputTokens: o.outputTokens,
4410
4427
  cacheReadTokens: o.cacheReadTokens, cacheCreateTokens: o.cacheCreateTokens,
4411
4428
  }, consumer));
4429
+ if (cacheTraceLines && o.cache)
4430
+ console.log(formatCacheTraceLine(codexReq, o.cache.key, o.cache, o, o.alias));
4412
4431
  };
4413
4432
  // Mid-flight seat failover. A 429 lands BEFORE any body is written —
4414
4433
  // forwardToCodex only returns false on the decline path — so the same
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. 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.1",
3
+ "version": "6.18.0",
4
4
  "description": "Use your Claude and ChatGPT subscriptions in Cursor, Cline, Aider, Claude Code and the Agent SDK at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint: either plan answers either wire shape, opt-in failover when one hits its limit, and a ledger of what each plan absorbed.",
5
5
  "type": "module",
6
6
  "bin": {