@askalf/dario 6.0.29 → 6.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  <p><strong>One local endpoint. Every AI tool you own. The subscriptions you already pay for.</strong></p>
20
20
 
21
- <sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~30k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
21
+ <sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~31k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
22
22
 
23
23
  <sub>Part of <a href="#own-your-stack"><strong>Own Your Stack</strong></a> — 11 open tools for owning your AI infra: <a href="https://github.com/askalf/redstamp">redstamp</a> · <a href="https://github.com/askalf/truecopy">truecopy</a> · <a href="https://github.com/askalf/fieldpass">fieldpass</a> · <a href="https://github.com/askalf/plumbline">plumbline</a> · <a href="#own-your-stack">full family ↓</a></sub>
24
24
 
@@ -167,6 +167,8 @@ Streaming, tool calls, and tool-result round trips work on both shapes: dario tr
167
167
 
168
168
  **Chat/completions fidelity:** text and `image_url` user-content parts (HTTPS URLs and data URIs, including the `detail` fidelity setting) carry through to Responses input items. The Codex subscription backend does not accept every chat field, so `response_format`, `stop`, `n`, `logprobs`, `stream_options` and other unmapped chat-only fields are intentionally lossy — and so are the sampling parameters `temperature`, `top_p`, `max_tokens` and `max_completion_tokens`, which translate cleanly but are then rejected by the backend and stripped before the request goes out. With `--verbose`, dario reports each field that does not reach Codex once per process.
169
169
 
170
+ **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.
171
+
170
172
  Codex accounts live in `~/.dario/codex-accounts/`, entirely separate from the Claude pool. Nothing about `dario login`, `dario accounts`, or Claude routing changes.
171
173
 
172
174
  ---
@@ -309,7 +311,7 @@ The split isn't live, but it was announced once on short notice and could return
309
311
 
310
312
  | Signal | Status |
311
313
  |---|---|
312
- | Source | **~30k** lines of TypeScript across **65** files — auditable in a weekend (v5 removed shim; the pool is the one code path) |
314
+ | Source | **~31k** lines of TypeScript across **67** files — auditable in a weekend (v5 removed shim; the pool is the one code path) |
313
315
  | Dependencies | **0 runtime.** Verify: `npm ls --production` |
314
316
  | Provenance | Every release [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions + Sigstore |
315
317
  | Scanning | [CodeQL](https://github.com/askalf/dario/actions/workflows/codeql.yml) on every push and weekly |
@@ -83,6 +83,13 @@ export declare const CODEX_CLAIM = "chatgpt_subscription";
83
83
  * cache-TTL discussion turns on. Output tokens are excluded from the ratio
84
84
  * (they are never cacheable). Pure + total-zero-safe for unit testing.
85
85
  */
86
+ /**
87
+ * Share of PROMPT tokens served from cache: cache_read / (input + cache_read +
88
+ * cache_create), as a percentage with two decimals (the same rounding as
89
+ * subscriptionPercent). Output tokens are excluded; they are never cacheable.
90
+ * Zero-safe. The single definition behind the summary's cache fields.
91
+ */
92
+ export declare function cachedPromptPercent(inputTokens: number, cacheReadTokens: number, cacheCreateTokens: number): number;
86
93
  export declare function formatUsageLogLine(requestCount: number, u: {
87
94
  inputTokens?: number;
88
95
  outputTokens?: number;
@@ -200,6 +207,10 @@ interface PerAccountStat {
200
207
  requests: number;
201
208
  inputTokens: number;
202
209
  outputTokens: number;
210
+ cacheReadTokens: number;
211
+ cacheCreateTokens: number;
212
+ /** Share of this account's prompt tokens served from cache (see cachedPromptPercent). */
213
+ cachedPromptPercent: number;
203
214
  estimatedCost: number;
204
215
  currentUtil5h: number;
205
216
  currentUtil7d: number;
@@ -210,12 +221,25 @@ interface PerModelStat {
210
221
  avgInputTokens: number;
211
222
  avgOutputTokens: number;
212
223
  avgThinkingTokens: number;
224
+ avgCacheReadTokens: number;
225
+ avgCacheCreateTokens: number;
226
+ /** Share of this model's prompt tokens served from cache (see cachedPromptPercent). */
227
+ cachedPromptPercent: number;
213
228
  estimatedCost: number;
214
229
  }
215
230
  interface WindowStats {
216
231
  totalInputTokens: number;
217
232
  totalOutputTokens: number;
218
233
  totalThinkingTokens: number;
234
+ totalCacheReadTokens: number;
235
+ totalCacheCreateTokens: number;
236
+ /**
237
+ * Share of prompt tokens served from cache across the window. The number
238
+ * that says whether a long-running session is being re-billed its prefix
239
+ * every turn (dario#678). Until now readable only off a -v console, one
240
+ * request at a time, and never for the codex engine at all.
241
+ */
242
+ cachedPromptPercent: number;
219
243
  estimatedCost: number;
220
244
  avgLatencyMs: number;
221
245
  errorRate: number;
package/dist/analytics.js CHANGED
@@ -89,6 +89,16 @@ export const CODEX_CLAIM = 'chatgpt_subscription';
89
89
  * cache-TTL discussion turns on. Output tokens are excluded from the ratio
90
90
  * (they are never cacheable). Pure + total-zero-safe for unit testing.
91
91
  */
92
+ /**
93
+ * Share of PROMPT tokens served from cache: cache_read / (input + cache_read +
94
+ * cache_create), as a percentage with two decimals (the same rounding as
95
+ * subscriptionPercent). Output tokens are excluded; they are never cacheable.
96
+ * Zero-safe. The single definition behind the summary's cache fields.
97
+ */
98
+ export function cachedPromptPercent(inputTokens, cacheReadTokens, cacheCreateTokens) {
99
+ const promptTotal = inputTokens + cacheReadTokens + cacheCreateTokens;
100
+ return promptTotal > 0 ? Math.round((cacheReadTokens / promptTotal) * 10000) / 100 : 0;
101
+ }
92
102
  export function formatUsageLogLine(requestCount, u) {
93
103
  const inp = u.inputTokens ?? 0;
94
104
  const out = u.outputTokens ?? 0;
@@ -275,6 +285,7 @@ export class Analytics extends EventEmitter {
275
285
  if (records.length === 0) {
276
286
  return {
277
287
  totalInputTokens: 0, totalOutputTokens: 0, totalThinkingTokens: 0,
288
+ totalCacheReadTokens: 0, totalCacheCreateTokens: 0, cachedPromptPercent: 0,
278
289
  estimatedCost: 0, avgLatencyMs: 0, errorRate: 0,
279
290
  claimBreakdown: {},
280
291
  billingBucketBreakdown: {
@@ -290,6 +301,8 @@ export class Analytics extends EventEmitter {
290
301
  const totalInput = records.reduce((s, r) => s + r.inputTokens, 0);
291
302
  const totalOutput = records.reduce((s, r) => s + r.outputTokens, 0);
292
303
  const totalThinking = records.reduce((s, r) => s + r.thinkingTokens, 0);
304
+ const totalCacheRead = records.reduce((s, r) => s + r.cacheReadTokens, 0);
305
+ const totalCacheCreate = records.reduce((s, r) => s + r.cacheCreateTokens, 0);
293
306
  const cost = records.reduce((s, r) => s + estimateCost(r), 0);
294
307
  const avgLatency = records.reduce((s, r) => s + r.latencyMs, 0) / records.length;
295
308
  const errors = records.filter(r => r.status >= 400).length;
@@ -314,6 +327,9 @@ export class Analytics extends EventEmitter {
314
327
  totalInputTokens: totalInput,
315
328
  totalOutputTokens: totalOutput,
316
329
  totalThinkingTokens: totalThinking,
330
+ totalCacheReadTokens: totalCacheRead,
331
+ totalCacheCreateTokens: totalCacheCreate,
332
+ cachedPromptPercent: cachedPromptPercent(totalInput, totalCacheRead, totalCacheCreate),
317
333
  estimatedCost: Math.round(cost * 10000) / 10000,
318
334
  avgLatencyMs: Math.round(avgLatency),
319
335
  errorRate: Math.round((errors / records.length) * 10000) / 10000,
@@ -330,10 +346,16 @@ export class Analytics extends EventEmitter {
330
346
  const result = {};
331
347
  for (const [account, recs] of Object.entries(grouped)) {
332
348
  const last = recs[recs.length - 1];
349
+ const inputTokens = recs.reduce((s, r) => s + r.inputTokens, 0);
350
+ const cacheReadTokens = recs.reduce((s, r) => s + r.cacheReadTokens, 0);
351
+ const cacheCreateTokens = recs.reduce((s, r) => s + r.cacheCreateTokens, 0);
333
352
  result[account] = {
334
353
  requests: recs.length,
335
- inputTokens: recs.reduce((s, r) => s + r.inputTokens, 0),
354
+ inputTokens,
336
355
  outputTokens: recs.reduce((s, r) => s + r.outputTokens, 0),
356
+ cacheReadTokens,
357
+ cacheCreateTokens,
358
+ cachedPromptPercent: cachedPromptPercent(inputTokens, cacheReadTokens, cacheCreateTokens),
337
359
  estimatedCost: Math.round(recs.reduce((s, r) => s + estimateCost(r), 0) * 10000) / 10000,
338
360
  currentUtil5h: last.util5h,
339
361
  currentUtil7d: last.util7d,
@@ -349,11 +371,17 @@ export class Analytics extends EventEmitter {
349
371
  }
350
372
  const result = {};
351
373
  for (const [model, recs] of Object.entries(grouped)) {
374
+ const inputTokens = recs.reduce((s, r) => s + r.inputTokens, 0);
375
+ const cacheReadTokens = recs.reduce((s, r) => s + r.cacheReadTokens, 0);
376
+ const cacheCreateTokens = recs.reduce((s, r) => s + r.cacheCreateTokens, 0);
352
377
  result[model] = {
353
378
  requests: recs.length,
354
- avgInputTokens: Math.round(recs.reduce((s, r) => s + r.inputTokens, 0) / recs.length),
379
+ avgInputTokens: Math.round(inputTokens / recs.length),
355
380
  avgOutputTokens: Math.round(recs.reduce((s, r) => s + r.outputTokens, 0) / recs.length),
356
381
  avgThinkingTokens: Math.round(recs.reduce((s, r) => s + r.thinkingTokens, 0) / recs.length),
382
+ avgCacheReadTokens: Math.round(cacheReadTokens / recs.length),
383
+ avgCacheCreateTokens: Math.round(cacheCreateTokens / recs.length),
384
+ cachedPromptPercent: cachedPromptPercent(inputTokens, cacheReadTokens, cacheCreateTokens),
357
385
  estimatedCost: Math.round(recs.reduce((s, r) => s + estimateCost(r), 0) * 10000) / 10000,
358
386
  };
359
387
  }
@@ -139,6 +139,9 @@ export type AnthropicStopReason = 'end_turn' | 'max_tokens' | 'tool_use' | 'stop
139
139
  export interface AnthropicUsage {
140
140
  input_tokens: number;
141
141
  output_tokens: number;
142
+ /** Present when the Responses usage carried `input_tokens_details`. */
143
+ cache_read_input_tokens?: number;
144
+ cache_creation_input_tokens?: number;
142
145
  }
143
146
  export interface AnthropicResponse {
144
147
  id: string;
@@ -234,6 +237,8 @@ export interface ResponsesRequest {
234
237
  top_p?: number;
235
238
  stream?: boolean;
236
239
  store?: boolean;
240
+ /** Routing hint for the backend's prompt cache; see `codexPromptCacheKey`. */
241
+ prompt_cache_key?: string;
237
242
  }
238
243
  export interface ResponsesOutputText {
239
244
  type: 'output_text';
@@ -296,6 +301,20 @@ export interface ResponsesUsage {
296
301
  };
297
302
  total_tokens?: number;
298
303
  }
304
+ /**
305
+ * Responses usage in Anthropic terms.
306
+ *
307
+ * The two APIs count cached prompt tokens differently: OpenAI's
308
+ * `input_tokens` INCLUDES the cached prefix and reports it again under
309
+ * `input_tokens_details.cached_tokens`; Anthropic's `input_tokens` EXCLUDES
310
+ * it and reports it beside as `cache_read_input_tokens`. A client summing the
311
+ * Anthropic fields (Claude Code's context meter does) would count the cached
312
+ * prefix twice if the number were copied across, so it is netted out here.
313
+ * `cache_write_tokens` (the 24h-retention write on newer models) maps to
314
+ * `cache_creation_input_tokens` the same way. Without `input_tokens_details`
315
+ * the usage stays two-field, exactly as before.
316
+ */
317
+ export declare function anthropicUsageFromResponses(u: ResponsesUsage | null | undefined): AnthropicUsage;
299
318
  export interface ResponsesResponse {
300
319
  id?: string;
301
320
  object?: string;
@@ -417,6 +436,8 @@ export type ResponsesAnthropicStreamEvent = {
417
436
  usage: {
418
437
  output_tokens: number;
419
438
  input_tokens?: number;
439
+ cache_read_input_tokens?: number;
440
+ cache_creation_input_tokens?: number;
420
441
  };
421
442
  } | {
422
443
  type: 'message_stop';
@@ -78,6 +78,34 @@
78
78
  */
79
79
  export const REASONING_EFFORT_LOW_MAX = 4096;
80
80
  export const REASONING_EFFORT_MEDIUM_MAX = 16384;
81
+ /**
82
+ * Responses usage in Anthropic terms.
83
+ *
84
+ * The two APIs count cached prompt tokens differently: OpenAI's
85
+ * `input_tokens` INCLUDES the cached prefix and reports it again under
86
+ * `input_tokens_details.cached_tokens`; Anthropic's `input_tokens` EXCLUDES
87
+ * it and reports it beside as `cache_read_input_tokens`. A client summing the
88
+ * Anthropic fields (Claude Code's context meter does) would count the cached
89
+ * prefix twice if the number were copied across, so it is netted out here.
90
+ * `cache_write_tokens` (the 24h-retention write on newer models) maps to
91
+ * `cache_creation_input_tokens` the same way. Without `input_tokens_details`
92
+ * the usage stays two-field, exactly as before.
93
+ */
94
+ export function anthropicUsageFromResponses(u) {
95
+ const input = typeof u?.input_tokens === 'number' ? u.input_tokens : 0;
96
+ const output = typeof u?.output_tokens === 'number' ? u.output_tokens : 0;
97
+ const d = u?.input_tokens_details;
98
+ if (!d || typeof d !== 'object')
99
+ return { input_tokens: input, output_tokens: output };
100
+ const cached = typeof d.cached_tokens === 'number' && d.cached_tokens > 0 ? d.cached_tokens : 0;
101
+ const written = typeof d.cache_write_tokens === 'number' && d.cache_write_tokens > 0 ? d.cache_write_tokens : 0;
102
+ return {
103
+ input_tokens: Math.max(0, input - cached - written),
104
+ output_tokens: output,
105
+ cache_read_input_tokens: cached,
106
+ cache_creation_input_tokens: written,
107
+ };
108
+ }
81
109
  // ─────────────────────────────────────────────────────────────────────
82
110
  // Small local helpers.
83
111
  // Thresholds are IMPORTED so the two translators stay in lock-step.
@@ -491,10 +519,7 @@ export function responsesToAnthropicResponse(resp, requestModel) {
491
519
  content,
492
520
  stop_reason: deriveStopReason(resp, sawToolCall),
493
521
  stop_sequence: null,
494
- usage: {
495
- input_tokens: resp.usage?.input_tokens ?? 0,
496
- output_tokens: resp.usage?.output_tokens ?? 0,
497
- },
522
+ usage: anthropicUsageFromResponses(resp.usage),
498
523
  };
499
524
  }
500
525
  const strOr = (v) => (typeof v === 'string' ? v : '');
@@ -626,8 +651,16 @@ export function responsesStreamToAnthropicSSE(options = {}) {
626
651
  const usageOut = {
627
652
  output_tokens: numOr(r.usage?.output_tokens, 0),
628
653
  };
629
- if (typeof r.usage?.input_tokens === 'number')
630
- usageOut.input_tokens = r.usage.input_tokens;
654
+ if (typeof r.usage?.input_tokens === 'number') {
655
+ // Same netting as the non-streaming body (anthropicUsageFromResponses):
656
+ // the cached prefix is reported beside input_tokens, not inside it.
657
+ const u = anthropicUsageFromResponses(r.usage);
658
+ usageOut.input_tokens = u.input_tokens;
659
+ if (u.cache_read_input_tokens !== undefined)
660
+ usageOut.cache_read_input_tokens = u.cache_read_input_tokens;
661
+ if (u.cache_creation_input_tokens !== undefined)
662
+ usageOut.cache_creation_input_tokens = u.cache_creation_input_tokens;
663
+ }
631
664
  events.push({
632
665
  type: 'message_delta',
633
666
  delta: { stop_reason: deriveStopReason(r, sawToolCall), stop_sequence: null },
@@ -837,6 +870,10 @@ export function createAnthropicMessageAssembler() {
837
870
  output_tokens: e.usage.output_tokens,
838
871
  input_tokens: e.usage.input_tokens ?? u.input_tokens,
839
872
  };
873
+ if (e.usage.cache_read_input_tokens !== undefined)
874
+ msg.usage.cache_read_input_tokens = e.usage.cache_read_input_tokens;
875
+ if (e.usage.cache_creation_input_tokens !== undefined)
876
+ msg.usage.cache_creation_input_tokens = e.usage.cache_creation_input_tokens;
840
877
  }
841
878
  }
842
879
  }
@@ -1,28 +1,6 @@
1
- /**
2
- * Codex backend — request path for the "altman" engine (dario#1009/#1010).
3
- *
4
- * The ChatGPT subscription is NOT an api.openai.com API key: it can't be used
5
- * with `Authorization: Bearer sk-…` against the public API. OpenAI's own `codex`
6
- * CLI sends the OAuth access_token as a bearer to the ChatGPT Codex backend's
7
- * Responses endpoint, with the workspace id from the id_token as a header.
8
- * Mirrored from the CLI source rather than guessed:
9
- *
10
- * base URL codex-rs/model-provider-info/src/lib.rs
11
- * `CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"`
12
- * (used as the default base_url whenever auth_mode is Chatgpt)
13
- * wire api same file, `WireApi::Responses` — "the Responses API exposed by
14
- * OpenAI at /v1/responses", i.e. `${base}/responses`
15
- * headers codex-rs/model-provider/src/bearer_auth_provider.rs
16
- * `Authorization: Bearer <access_token>` + `ChatGPT-Account-ID: <id>`
17
- * account id codex-rs/login/src/token_data.rs — id_token claim
18
- * `https://api.openai.com/auth`.chatgpt_account_id
19
- *
20
- * dario's inbound is OpenAI chat/completions — what any OpenAI-compatible
21
- * client speaks — so this module owns the chat/completions ⇄ Responses
22
- * translation in both directions, including SSE.
23
- */
24
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
25
2
  import type { CodexAccountCredentials } from './codex-accounts.js';
3
+ import { type ResponsesUsage } from './anthropic-responses-translate.js';
26
4
  import { type ModelResolver, type ClaudeTarget } from './claude-model.js';
27
5
  export declare const CODEX_BACKEND_BASE_URL: string;
28
6
  /**
@@ -54,8 +32,11 @@ export declare function fetchCodexModels(creds: CodexAccountCredentials, fetchIm
54
32
  export interface CodexForwardOutcome {
55
33
  status: number;
56
34
  latencyMs: number;
35
+ /** Net of the cached prefix (Anthropic convention; see splitResponsesUsage). */
57
36
  inputTokens: number;
58
37
  outputTokens: number;
38
+ cacheReadTokens: number;
39
+ cacheCreateTokens: number;
59
40
  stream: boolean;
60
41
  model: string;
61
42
  alias: string;
@@ -161,6 +142,61 @@ export declare function isFailedResponse(resp: unknown): boolean;
161
142
  export declare function failedResponseMessage(resp: unknown): string;
162
143
  /** The upstream error code on a failed Responses payload, or null when absent. */
163
144
  export declare function failedResponseCode(resp: unknown): string | null;
145
+ /** chat/completions `usage`; `prompt_tokens_details` only when upstream reported details. */
146
+ export interface ChatCompletionsUsage {
147
+ prompt_tokens: number;
148
+ completion_tokens: number;
149
+ total_tokens: number;
150
+ prompt_tokens_details?: {
151
+ cached_tokens: number;
152
+ };
153
+ }
154
+ /**
155
+ * Responses usage -> chat/completions usage. Both OpenAI shapes count the
156
+ * cached prefix INSIDE the prompt total and repeat it under a details object,
157
+ * so this is a rename, not a subtraction: `input_tokens_details.cached_tokens`
158
+ * becomes `prompt_tokens_details.cached_tokens`, which is where every OpenAI
159
+ * SDK and cost dashboard already looks for it.
160
+ */
161
+ export declare function chatCompletionsUsage(u: ResponsesUsage): ChatCompletionsUsage;
162
+ /** Per-request token accounting for analytics and the request log. */
163
+ export interface CodexTokenUsage {
164
+ /** Net of the cached prefix: the Anthropic convention every analytics row uses. */
165
+ input: number;
166
+ output: number;
167
+ cacheRead: number;
168
+ cacheCreate: number;
169
+ }
170
+ /**
171
+ * Terminal Responses usage -> analytics accounting. Delegates the netting to
172
+ * anthropicUsageFromResponses so the analytics row and the Anthropic-shape
173
+ * wire body can never disagree about what "input" means. Null when the
174
+ * stream never delivered usage.
175
+ */
176
+ export declare function splitResponsesUsage(u: unknown): CodexTokenUsage | null;
177
+ /**
178
+ * The `prompt_cache_key` sent with every Codex request.
179
+ *
180
+ * The backend caches prompt prefixes on its own (1,024 tokens and up, about
181
+ * 30 minutes), but the key is what routes same-prefix requests to the machine
182
+ * that holds the cache; OpenAI's own wording is that it "influences routing".
183
+ * The Codex CLI sends one on every turn (its session id). dario sent none, so
184
+ * a fleet re-sending the same 20KB system prompt every few minutes was routed
185
+ * blind. Resolution order:
186
+ *
187
+ * 1. the client's own key, when the chat body carried one: it knows its
188
+ * conversation better than any derivation here;
189
+ * 2. an Anthropic-shape `metadata.user_id`: Claude Code stamps one per
190
+ * session, so a session shares a key across its turns. Hashed, because
191
+ * the value embeds the client's Anthropic account and session ids and
192
+ * neither has any business reaching a second vendor in the clear;
193
+ * 3. the request's own stable prefix: model, instructions and tool names.
194
+ * Every caller sending the same system prompt and tool set lands on the
195
+ * same key, which is exactly the grouping the cache wants.
196
+ *
197
+ * Pure. The key carries no content, only a truncated SHA-256 of it.
198
+ */
199
+ export declare function codexPromptCacheKey(shape: CodexRequestShape, clientBody: Record<string, unknown>, upstreamBody: Record<string, unknown>): string;
164
200
  /**
165
201
  * Stateful per-request translator: Responses SSE in, chat/completions out.
166
202
  *
@@ -183,11 +219,9 @@ export declare function createResponsesTranslator(model: string): {
183
219
  /** Token usage from the terminal event, or null if none arrived. Read by
184
220
  * the proxy to record the request in analytics — before this, codex
185
221
  * requests were invisible to /analytics and the request log entirely. */
186
- usage(): {
187
- prompt_tokens: number;
188
- completion_tokens: number;
189
- total_tokens: number;
190
- } | null;
222
+ usage(): ChatCompletionsUsage | null;
223
+ /** The same terminal usage split for analytics (input net of cache). */
224
+ tokens(): CodexTokenUsage | null;
191
225
  /** Everything seen so far, as one non-streaming chat.completion body. */
192
226
  complete(): Record<string, unknown>;
193
227
  };
@@ -1,4 +1,28 @@
1
- import { anthropicToResponsesRequest, createResponsesSSEParser, formatResponsesAnthropicSSE, createAnthropicMessageAssembler, responsesStreamToAnthropicSSE, } from './anthropic-responses-translate.js';
1
+ /**
2
+ * Codex backend — request path for the "altman" engine (dario#1009/#1010).
3
+ *
4
+ * The ChatGPT subscription is NOT an api.openai.com API key: it can't be used
5
+ * with `Authorization: Bearer sk-…` against the public API. OpenAI's own `codex`
6
+ * CLI sends the OAuth access_token as a bearer to the ChatGPT Codex backend's
7
+ * Responses endpoint, with the workspace id from the id_token as a header.
8
+ * Mirrored from the CLI source rather than guessed:
9
+ *
10
+ * base URL codex-rs/model-provider-info/src/lib.rs
11
+ * `CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"`
12
+ * (used as the default base_url whenever auth_mode is Chatgpt)
13
+ * wire api same file, `WireApi::Responses` — "the Responses API exposed by
14
+ * OpenAI at /v1/responses", i.e. `${base}/responses`
15
+ * headers codex-rs/model-provider/src/bearer_auth_provider.rs
16
+ * `Authorization: Bearer <access_token>` + `ChatGPT-Account-ID: <id>`
17
+ * account id codex-rs/login/src/token_data.rs — id_token claim
18
+ * `https://api.openai.com/auth`.chatgpt_account_id
19
+ *
20
+ * dario's inbound is OpenAI chat/completions — what any OpenAI-compatible
21
+ * client speaks — so this module owns the chat/completions ⇄ Responses
22
+ * translation in both directions, including SSE.
23
+ */
24
+ import { createHash } from 'node:crypto';
25
+ import { anthropicToResponsesRequest, anthropicUsageFromResponses, createResponsesSSEParser, formatResponsesAnthropicSSE, createAnthropicMessageAssembler, responsesStreamToAnthropicSSE, } from './anthropic-responses-translate.js';
2
26
  import { resolveClaudeTarget } from './claude-model.js';
3
27
  import { BAKED_BASE_MODELS } from './model-catalog.js';
4
28
  import { parseRetryAfterMs } from './provider-cooldown.js';
@@ -198,6 +222,7 @@ const CHAT_COMPLETIONS_FIELD_TRANSLATIONS = {
198
222
  tool_choice: 'tool_choice',
199
223
  stream: 'stream',
200
224
  reasoning_effort: 'reasoning',
225
+ prompt_cache_key: 'prompt_cache_key',
201
226
  temperature: 'temperature',
202
227
  top_p: 'top_p',
203
228
  max_tokens: 'max_output_tokens',
@@ -365,6 +390,12 @@ export function chatCompletionsToResponses(body) {
365
390
  }
366
391
  if (body.reasoning_effort != null)
367
392
  out.reasoning = { effort: body.reasoning_effort };
393
+ // The public chat/completions API takes this field; a client that sets one
394
+ // (a harness keying on its own conversation id) knows its prefix better
395
+ // than any derivation here can.
396
+ if (typeof body.prompt_cache_key === 'string' && body.prompt_cache_key.length > 0) {
397
+ out.prompt_cache_key = body.prompt_cache_key;
398
+ }
368
399
  return out;
369
400
  }
370
401
  /**
@@ -398,6 +429,81 @@ export function failedResponseCode(resp) {
398
429
  const e = resp?.error;
399
430
  return e && typeof e.code === 'string' && e.code ? e.code : null;
400
431
  }
432
+ /**
433
+ * Responses usage -> chat/completions usage. Both OpenAI shapes count the
434
+ * cached prefix INSIDE the prompt total and repeat it under a details object,
435
+ * so this is a rename, not a subtraction: `input_tokens_details.cached_tokens`
436
+ * becomes `prompt_tokens_details.cached_tokens`, which is where every OpenAI
437
+ * SDK and cost dashboard already looks for it.
438
+ */
439
+ export function chatCompletionsUsage(u) {
440
+ const prompt = typeof u.input_tokens === 'number' ? u.input_tokens : 0;
441
+ const completion = typeof u.output_tokens === 'number' ? u.output_tokens : 0;
442
+ const out = { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion };
443
+ const d = u.input_tokens_details;
444
+ if (d && typeof d === 'object') {
445
+ out.prompt_tokens_details = { cached_tokens: typeof d.cached_tokens === 'number' && d.cached_tokens > 0 ? d.cached_tokens : 0 };
446
+ }
447
+ return out;
448
+ }
449
+ /**
450
+ * Terminal Responses usage -> analytics accounting. Delegates the netting to
451
+ * anthropicUsageFromResponses so the analytics row and the Anthropic-shape
452
+ * wire body can never disagree about what "input" means. Null when the
453
+ * stream never delivered usage.
454
+ */
455
+ export function splitResponsesUsage(u) {
456
+ if (!u || typeof u !== 'object')
457
+ return null;
458
+ const a = anthropicUsageFromResponses(u);
459
+ return {
460
+ input: a.input_tokens,
461
+ output: a.output_tokens,
462
+ cacheRead: a.cache_read_input_tokens ?? 0,
463
+ cacheCreate: a.cache_creation_input_tokens ?? 0,
464
+ };
465
+ }
466
+ /**
467
+ * The `prompt_cache_key` sent with every Codex request.
468
+ *
469
+ * The backend caches prompt prefixes on its own (1,024 tokens and up, about
470
+ * 30 minutes), but the key is what routes same-prefix requests to the machine
471
+ * that holds the cache; OpenAI's own wording is that it "influences routing".
472
+ * The Codex CLI sends one on every turn (its session id). dario sent none, so
473
+ * a fleet re-sending the same 20KB system prompt every few minutes was routed
474
+ * blind. Resolution order:
475
+ *
476
+ * 1. the client's own key, when the chat body carried one: it knows its
477
+ * conversation better than any derivation here;
478
+ * 2. an Anthropic-shape `metadata.user_id`: Claude Code stamps one per
479
+ * session, so a session shares a key across its turns. Hashed, because
480
+ * the value embeds the client's Anthropic account and session ids and
481
+ * neither has any business reaching a second vendor in the clear;
482
+ * 3. the request's own stable prefix: model, instructions and tool names.
483
+ * Every caller sending the same system prompt and tool set lands on the
484
+ * same key, which is exactly the grouping the cache wants.
485
+ *
486
+ * Pure. The key carries no content, only a truncated SHA-256 of it.
487
+ */
488
+ export function codexPromptCacheKey(shape, clientBody, upstreamBody) {
489
+ const own = upstreamBody.prompt_cache_key;
490
+ if (typeof own === 'string' && own.length > 0)
491
+ return own;
492
+ const h = createHash('sha256');
493
+ if (shape === 'anthropic') {
494
+ const meta = clientBody.metadata;
495
+ if (meta && typeof meta.user_id === 'string' && meta.user_id.length > 0) {
496
+ h.update('session\0').update(meta.user_id);
497
+ return `dario-${h.digest('hex').slice(0, 32)}`;
498
+ }
499
+ }
500
+ h.update('prefix\0').update(String(upstreamBody.model ?? '')).update('\0');
501
+ h.update(typeof upstreamBody.instructions === 'string' ? upstreamBody.instructions : '').update('\0');
502
+ const tools = Array.isArray(upstreamBody.tools) ? upstreamBody.tools : [];
503
+ for (const t of tools)
504
+ h.update(typeof t?.name === 'string' ? t.name : '').update('\0');
505
+ return `dario-${h.digest('hex').slice(0, 32)}`;
506
+ }
401
507
  /**
402
508
  * Stateful per-request translator: Responses SSE in, chat/completions out.
403
509
  *
@@ -417,6 +523,7 @@ export function createResponsesTranslator(model) {
417
523
  let id = 'chatcmpl-dario';
418
524
  let text = '';
419
525
  let usage = null;
526
+ let rawUsage = null;
420
527
  const toolCalls = new Map();
421
528
  let nextToolIndex = 0;
422
529
  let roleSent = false;
@@ -505,11 +612,8 @@ export function createResponsesTranslator(model) {
505
612
  const r = e.response;
506
613
  const u = r?.usage;
507
614
  if (u) {
508
- usage = {
509
- prompt_tokens: u.input_tokens ?? 0,
510
- completion_tokens: u.output_tokens ?? 0,
511
- total_tokens: (u.input_tokens ?? 0) + (u.output_tokens ?? 0),
512
- };
615
+ rawUsage = u;
616
+ usage = chatCompletionsUsage(u);
513
617
  }
514
618
  if (failed) {
515
619
  // A failed turn must NOT close like a finished one: a
@@ -540,6 +644,10 @@ export function createResponsesTranslator(model) {
540
644
  usage() {
541
645
  return usage;
542
646
  },
647
+ /** The same terminal usage split for analytics (input net of cache). */
648
+ tokens() {
649
+ return splitResponsesUsage(rawUsage);
650
+ },
543
651
  /** Everything seen so far, as one non-streaming chat.completion body. */
544
652
  complete() {
545
653
  const calls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
@@ -583,6 +691,9 @@ export function createResponsesTranslator(model) {
583
691
  export const CODEX_SUPPORTED_FIELDS = [
584
692
  'model', 'input', 'stream', 'store', 'instructions',
585
693
  'tools', 'tool_choice', 'parallel_tool_calls', 'reasoning',
694
+ // Sent by the Codex CLI on every request (codex-rs ResponsesApiRequest), so
695
+ // accepted by construction; see codexPromptCacheKey.
696
+ 'prompt_cache_key',
586
697
  ];
587
698
  /** Drop every field this backend does not accept. Pure; exported for tests. */
588
699
  export function toCodexSupportedBody(body) {
@@ -648,7 +759,12 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
648
759
  return;
649
760
  reported = true;
650
761
  try {
651
- onDone({ status, latencyMs: Date.now() - startedAt, inputTokens: usage?.input ?? 0, outputTokens: usage?.output ?? 0, stream, model, alias: creds.alias });
762
+ onDone({
763
+ status, latencyMs: Date.now() - startedAt,
764
+ inputTokens: usage?.input ?? 0, outputTokens: usage?.output ?? 0,
765
+ cacheReadTokens: usage?.cacheRead ?? 0, cacheCreateTokens: usage?.cacheCreate ?? 0,
766
+ stream, model, alias: creds.alias,
767
+ });
652
768
  }
653
769
  catch { /* a reporting failure must never break a served request */ }
654
770
  };
@@ -675,7 +791,10 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
675
791
  const upstreamBody = isAnthropic
676
792
  ? { ...anthropicToResponsesRequest(parsed, model), stream: true }
677
793
  : chatCompletionsToResponses(parsed);
678
- const scrubbed = toCodexSupportedBody(upstreamBody);
794
+ const scrubbed = toCodexSupportedBody({
795
+ ...upstreamBody,
796
+ prompt_cache_key: codexPromptCacheKey(shape, parsed, upstreamBody),
797
+ });
679
798
  const target = `${CODEX_BACKEND_BASE_URL.replace(/\/$/, '')}/responses`;
680
799
  const abort = new AbortController();
681
800
  // Once the client is gone there is nobody left to write to, but the upstream
@@ -763,8 +882,8 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
763
882
  // Reported on the abandoned-client exit as well as the normal one, so a
764
883
  // stream the client walked away from still shows what it already spent.
765
884
  usageSoFar = isAnthropic
766
- ? () => { const tr = terminalResponse; return tr?.usage ? { input: Number(tr.usage.input_tokens ?? 0), output: Number(tr.usage.output_tokens ?? 0) } : null; }
767
- : () => { const u = translator.usage(); return u ? { input: u.prompt_tokens, output: u.completion_tokens } : null; };
885
+ ? () => splitResponsesUsage(terminalResponse?.usage)
886
+ : () => translator.tokens();
768
887
  const emitAnthropic = (events) => {
769
888
  for (const ev of events) {
770
889
  const t = ev.type ?? '';
package/dist/pool.d.ts CHANGED
@@ -118,6 +118,41 @@ export type AccountIneligibility = 'rate-limited' | 'token-expired' | 'auth-cool
118
118
  * need to name the failure, and a boolean forces each of them to re-derive it
119
119
  * and drift again.
120
120
  */
121
+ /**
122
+ * Has the window that produced a `rejected` reading rolled over?
123
+ *
124
+ * A rejection is a verdict with an expiry date: `anthropic-ratelimit-unified-reset`
125
+ * names the moment the window that refused the request resets. Past it, the old
126
+ * reading says nothing about the account any more.
127
+ *
128
+ * This matters because a rejected account is filtered out of `select()`, so it
129
+ * is sent no further requests, so `updateRateLimits` never runs for it and its
130
+ * snapshot never refreshes. The only routes back into rotation were the
131
+ * all-exhausted fallback in `select()` and a proxy restart. Observed on the
132
+ * fleet box (2026-09-06): one seat sat parked on a 106% five-hour reading while
133
+ * a second subscription carried every request, and it would have stayed parked
134
+ * past its own reset for as long as the other seat held out.
135
+ *
136
+ * `reset` is epoch SECONDS — the header's own unit, the same one `formatReset`
137
+ * scales — so it is converted here. A snapshot with no reset (0) keeps its
138
+ * rejection: with no stated rollover there is nothing to expire, and guessing
139
+ * would push a genuinely throttled account back into rotation.
140
+ */
141
+ export declare function rateLimitWindowPassed(rl: RateLimitSnapshot, now?: number): boolean;
142
+ /**
143
+ * The status string the operator-facing surfaces report for one account —
144
+ * `GET /accounts` and `GET /admin/accounts`, which must agree with each other
145
+ * and with what routing actually does.
146
+ *
147
+ * Auth cool-down outranks the rate-limit reading: a 401 streak is both the more
148
+ * urgent fact and the one the rate-limit headers cannot describe, since 401
149
+ * responses carry none. An expired rejection degrades to `unknown` rather than
150
+ * `allowed` — the window rolled over, but nothing has measured the account
151
+ * since, and reporting `allowed` would assert a serving capacity no request has
152
+ * demonstrated. `unknown` is what a never-used account already reports, which is
153
+ * exactly the state this is: no current observation.
154
+ */
155
+ export declare function reportedAccountStatus(account: PoolAccount, now?: number): string;
121
156
  export declare function accountIneligibility(account: PoolAccount, now?: number): AccountIneligibility | null;
122
157
  /** Boolean form of `accountIneligibility` — the router's eligibility filter. */
123
158
  export declare function isAccountEligible(account: PoolAccount, now?: number): boolean;
package/dist/pool.js CHANGED
@@ -100,8 +100,54 @@ export const TOKEN_EXPIRY_MARGIN_MS = 30_000;
100
100
  * need to name the failure, and a boolean forces each of them to re-derive it
101
101
  * and drift again.
102
102
  */
103
+ /**
104
+ * Has the window that produced a `rejected` reading rolled over?
105
+ *
106
+ * A rejection is a verdict with an expiry date: `anthropic-ratelimit-unified-reset`
107
+ * names the moment the window that refused the request resets. Past it, the old
108
+ * reading says nothing about the account any more.
109
+ *
110
+ * This matters because a rejected account is filtered out of `select()`, so it
111
+ * is sent no further requests, so `updateRateLimits` never runs for it and its
112
+ * snapshot never refreshes. The only routes back into rotation were the
113
+ * all-exhausted fallback in `select()` and a proxy restart. Observed on the
114
+ * fleet box (2026-09-06): one seat sat parked on a 106% five-hour reading while
115
+ * a second subscription carried every request, and it would have stayed parked
116
+ * past its own reset for as long as the other seat held out.
117
+ *
118
+ * `reset` is epoch SECONDS — the header's own unit, the same one `formatReset`
119
+ * scales — so it is converted here. A snapshot with no reset (0) keeps its
120
+ * rejection: with no stated rollover there is nothing to expire, and guessing
121
+ * would push a genuinely throttled account back into rotation.
122
+ */
123
+ export function rateLimitWindowPassed(rl, now = Date.now()) {
124
+ return rl.reset > 0 && rl.reset * 1000 <= now;
125
+ }
126
+ /**
127
+ * The status string the operator-facing surfaces report for one account —
128
+ * `GET /accounts` and `GET /admin/accounts`, which must agree with each other
129
+ * and with what routing actually does.
130
+ *
131
+ * Auth cool-down outranks the rate-limit reading: a 401 streak is both the more
132
+ * urgent fact and the one the rate-limit headers cannot describe, since 401
133
+ * responses carry none. An expired rejection degrades to `unknown` rather than
134
+ * `allowed` — the window rolled over, but nothing has measured the account
135
+ * since, and reporting `allowed` would assert a serving capacity no request has
136
+ * demonstrated. `unknown` is what a never-used account already reports, which is
137
+ * exactly the state this is: no current observation.
138
+ */
139
+ export function reportedAccountStatus(account, now = Date.now()) {
140
+ if (isInAuthCooldown(account, now))
141
+ return 'auth-cooldown';
142
+ if (account.rateLimit.status === 'rejected' && rateLimitWindowPassed(account.rateLimit, now))
143
+ return 'unknown';
144
+ return account.rateLimit.status;
145
+ }
103
146
  export function accountIneligibility(account, now = Date.now()) {
104
- if (account.rateLimit.status === 'rejected')
147
+ // A rejection outlives its own window unless it is allowed to expire:
148
+ // nothing refreshes a parked account's snapshot, because being parked is
149
+ // what stops it being sent requests.
150
+ if (account.rateLimit.status === 'rejected' && !rateLimitWindowPassed(account.rateLimit, now))
105
151
  return 'rate-limited';
106
152
  if (account.expiresAt <= now + TOKEN_EXPIRY_MARGIN_MS)
107
153
  return 'token-expired';
package/dist/proxy.js CHANGED
@@ -12,7 +12,7 @@ import { darioVersion } from './version.js';
12
12
  import { buildCCRequest, applyCcPromptCaching, isGenuineCCClient, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, overlayTemplateHeaderValues, forwardClientCCIdentityHeaders, isMcpToolName, CC_TEMPLATE, effectiveCacheControl, withForced1hBeta } from './cc-template.js';
13
13
  import { stampCch, hasCchSeed } from './cch.js';
14
14
  import { describeTemplate, detectDrift, checkCCCompat, probeInstalledCCVersion } from './live-fingerprint.js';
15
- import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reconcilePoolAccounts, resolvePoolStrategy, utilFreshness } from './pool.js';
15
+ import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, reconcilePoolAccounts, resolvePoolStrategy, utilFreshness } from './pool.js';
16
16
  import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS, CODEX_CLAIM } from './analytics.js';
17
17
  import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
18
18
  import { notify as osNotify } from './notify.js';
@@ -2101,7 +2101,7 @@ export async function startProxy(opts = {}) {
2101
2101
  // must not be the one place a stale reading still looks current.
2102
2102
  ...utilFreshness(a.rateLimit, snapNow),
2103
2103
  claim: a.rateLimit.claim,
2104
- status: isInAuthCooldown(a, snapNow) ? 'auth-cooldown' : a.rateLimit.status,
2104
+ status: reportedAccountStatus(a, snapNow),
2105
2105
  requestCount: a.requestCount,
2106
2106
  // Raw streak, not just the cooldown boolean: a single 401 also
2107
2107
  // shows `auth-cooldown` for 60s, indistinguishable from a
@@ -2200,7 +2200,7 @@ export async function startProxy(opts = {}) {
2200
2200
  util7d: a.rateLimit.util7d,
2201
2201
  ...utilFreshness(a.rateLimit, now),
2202
2202
  claim: a.rateLimit.claim,
2203
- status: inCooldown ? 'auth-cooldown' : a.rateLimit.status,
2203
+ status: reportedAccountStatus(a, now),
2204
2204
  requestCount: a.requestCount,
2205
2205
  expiresInMs: Math.max(0, a.expiresAt - now),
2206
2206
  // Refresh-token grant age (refresh-grant.ts): the wall a token
@@ -3040,7 +3040,9 @@ export async function startProxy(opts = {}) {
3040
3040
  account: o.alias,
3041
3041
  model: o.model || rawModel || 'codex',
3042
3042
  inputTokens: o.inputTokens, outputTokens: o.outputTokens,
3043
- cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
3043
+ // Anthropic convention, like every other row: inputTokens is
3044
+ // net of the cached prefix, which sits in cacheReadTokens.
3045
+ cacheReadTokens: o.cacheReadTokens, cacheCreateTokens: o.cacheCreateTokens, thinkingTokens: 0,
3044
3046
  // No Anthropic rate-limit headers on this path; the claim
3045
3047
  // names the engine and is subscription billing, so the
3046
3048
  // overage guard (#288) leaves it alone.
@@ -3051,8 +3053,14 @@ export async function startProxy(opts = {}) {
3051
3053
  ts: new Date().toISOString(), req: codexReq,
3052
3054
  method: req.method ?? '', path: urlPath, model: o.model || rawModel || undefined,
3053
3055
  status: o.status, latency_ms: o.latencyMs, in_tokens: o.inputTokens, out_tokens: o.outputTokens,
3056
+ cache_read: o.cacheReadTokens, cache_create: o.cacheCreateTokens,
3054
3057
  claim: CODEX_CLAIM, bucket: 'subscription', account: o.alias, stream: o.stream,
3055
3058
  });
3059
+ if (verbose)
3060
+ console.log(formatUsageLogLine(codexReq, {
3061
+ inputTokens: o.inputTokens, outputTokens: o.outputTokens,
3062
+ cacheReadTokens: o.cacheReadTokens, cacheCreateTokens: o.cacheCreateTokens,
3063
+ }));
3056
3064
  },
3057
3065
  // Cool codex on a rate limit only — a 5xx or an unreachable backend
3058
3066
  // is an outage, and parking a provider for that would keep it out
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.0.29",
3
+ "version": "6.0.31",
4
4
  "description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
5
5
  "type": "module",
6
6
  "bin": {