@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.
- package/dist/analytics.d.ts +17 -0
- package/dist/cache-trace.d.ts +106 -0
- package/dist/cache-trace.js +176 -0
- package/dist/cli.js +8 -1
- package/dist/codex-backend.d.ts +40 -1
- package/dist/codex-backend.js +106 -7
- package/dist/proxy.d.ts +13 -0
- package/dist/proxy.js +103 -46
- package/docs/analytics.md +20 -0
- package/docs/commands.md +2 -1
- package/docs/docker.md +1 -0
- package/docs/multi-instance.md +6 -2
- package/docs/two-plans.md +2 -2
- package/package.json +1 -1
package/dist/analytics.d.ts
CHANGED
|
@@ -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
|
package/dist/codex-backend.d.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/codex-backend.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
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
|
|
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) |
|
package/docs/multi-instance.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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": {
|