@foldrun/core 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runner.ts CHANGED
@@ -75,13 +75,14 @@ import {
75
75
  type StepRecord,
76
76
  type StepAttempt,
77
77
  sumAttempts,
78
+ tokensOf,
78
79
  parseSchemaText,
79
80
  STORAGE_DIR,
80
81
  adoptLegacyVersionKey,
81
82
  listAgents,
82
83
  runCost,
83
84
  } from "./store.ts";
84
- import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, type Catalog } from "./catalog.ts";
85
+ import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf, type Catalog, type TokenPrice } from "./catalog.ts";
85
86
  import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.ts";
86
87
  import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.ts";
87
88
  import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.ts";
@@ -1712,7 +1713,7 @@ async function publishPublicDir(
1712
1713
  export function repriced(
1713
1714
  catalog: Catalog | null,
1714
1715
  model: string,
1715
- usage: { inputTokens: number; outputTokens: number } | null,
1716
+ usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number } | null,
1716
1717
  sdkCost: number | null,
1717
1718
  push: (type: "info" | "error", text: string) => void,
1718
1719
  /** The step's turns each priced at its own model — set when a sub-agent
@@ -2064,8 +2065,12 @@ async function runStep(
2064
2065
  // that names a provider uses its own; otherwise the platform's (in a
2065
2066
  // run container) or this process's — never a login that just happens
2066
2067
  // to be on the machine (model-credential.ts).
2067
- const credential = providerLabel ? null : resolveModelCredential(isolatedRun() ? platformModelEnv() : process.env);
2068
- step.credential = credential ? credential.label : `provider: ${providerLabel}`;
2068
+ // An account refused the platform's credential (below) is never shown
2069
+ // running on it: the record said "Claude login token" on a step that
2070
+ // was then refused for having no key (FOL-14, 18 Oct 2026).
2071
+ const noKey = !providerLabel && !platform.platformKeyAllowed(tenant);
2072
+ const credential = providerLabel || noKey ? null : resolveModelCredential(isolatedRun() ? platformModelEnv() : process.env);
2073
+ step.credential = credential ? credential.label : noKey ? "none — this account has no model key" : `provider: ${providerLabel}`;
2069
2074
  if (credential) {
2070
2075
  const line = credentialLine(credential);
2071
2076
  push(line.type, line.text);
@@ -2080,7 +2085,7 @@ async function runStep(
2080
2085
  push(
2081
2086
  "error",
2082
2087
  `no model key: this account runs on its own API key, and none is set. Add one in Settings → Model ` +
2083
- `(or \`foldrun model set <provider> --key …\`), or write a provider: block in AGENTS.md`,
2088
+ `(or \`foldrun account model set <provider> --key …\`), or write a provider: block in AGENTS.md`,
2084
2089
  );
2085
2090
  step.status = "failed";
2086
2091
  save();
@@ -2166,7 +2171,7 @@ async function runStep(
2166
2171
  // step on knowledge; unknown ids, presets and offline all pass through
2167
2172
  // to the model call, which stays the authority of last resort.
2168
2173
  let catalog: Catalog | null = null;
2169
- let tokenPrice: { input: number; output: number } | null = null;
2174
+ let tokenPrice: TokenPrice | null = null;
2170
2175
  // The env this run's model calls actually ride: the workspace's own
2171
2176
  // provider block when one is declared, otherwise the platform's — the
2172
2177
  // "models included" path, where the host env points every run at the
@@ -2192,10 +2197,7 @@ async function runStep(
2192
2197
  const verdict = checkModel(catalog, wireModel, { tools: needsTools });
2193
2198
  // Also what a token costs here, so a step can count its own spend
2194
2199
  // turn by turn against its ceiling instead of learning at the end.
2195
- const priced = findModel(catalog, wireModel);
2196
- if (priced && priced.promptPrice != null && priced.completionPrice != null) {
2197
- tokenPrice = { input: priced.promptPrice, output: priced.completionPrice };
2198
- }
2200
+ tokenPrice = tokenPriceOf(findModel(catalog, wireModel));
2199
2201
  if (!verdict.ok) {
2200
2202
  push("error", verdict.reason!);
2201
2203
  step.status = "failed";
@@ -2828,7 +2830,7 @@ async function runStep(
2828
2830
  step.podLostTry = {
2829
2831
  status: "lost",
2830
2832
  costUsd: repriced(catalog, wireModel, first.usage ?? null, first.costUsd, push, first.turnsCostUsd),
2831
- tokens: first.usage ? { input: first.usage.inputTokens, output: first.usage.outputTokens } : null,
2833
+ tokens: tokensOf(first.usage),
2832
2834
  computeSecs: first.timing ? Math.round(first.timing.totalMs) / 1000 : null,
2833
2835
  finishedAt: new Date().toISOString(),
2834
2836
  error: decision.note.slice(0, 500),
@@ -2872,9 +2874,7 @@ async function runStep(
2872
2874
  if (outcome.status === "failed" && outcome.reason) push("error", `sandbox ended: ${outcome.reason}`);
2873
2875
  if (outcome.data !== undefined) step.data = redactData(outcome.data);
2874
2876
  step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push, outcome.turnsCostUsd);
2875
- step.tokens = outcome.usage
2876
- ? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
2877
- : null;
2877
+ step.tokens = tokensOf(outcome.usage);
2878
2878
  step.actual = outcome.res ?? null;
2879
2879
  if (outcome.res?.peakMemBytes || outcome.res?.rxBytes) {
2880
2880
  const mb = (n: number | null) => (n === null ? "?" : (n / 1024 / 1024).toFixed(0));
@@ -3076,9 +3076,7 @@ async function runStep(
3076
3076
  const consultCost = consultTools.drainCost();
3077
3077
  const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push, outcome.turnsCostUsd);
3078
3078
  step.costUsd = stepCost === null && consultCost === 0 ? null : (stepCost ?? 0) + consultCost;
3079
- step.tokens = outcome.usage
3080
- ? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
3081
- : null;
3079
+ step.tokens = tokensOf(outcome.usage);
3082
3080
  for (const line of apiTools.drainLog()) push("info", `api: ${line}`);
3083
3081
  for (const line of scriptTools.drainLog()) push("info", `script: ${line}`);
3084
3082
  }
package/src/step-exec.ts CHANGED
@@ -15,6 +15,7 @@ import path from "node:path";
15
15
  import { query } from "@anthropic-ai/claude-agent-sdk";
16
16
  import type { HookInput, McpServerConfig, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
17
17
  import type { Effort } from "./store.ts";
18
+ import { costOf, type TokenPrice } from "./catalog.ts";
18
19
  import type { TestEffect } from "./test-mode.ts";
19
20
  import type { OperatorEvent } from "./operator.ts";
20
21
  import { spawn } from "node:child_process";
@@ -25,6 +26,26 @@ import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-sc
25
26
  import { CallCounter, limitKeysFor, type Limits } from "./limits.ts";
26
27
  import { classifyCall, podAt, readPodEvents, type BrowserPodOutcome, type CallKind, type ClassifyContext } from "./browser-pod.ts";
27
28
 
29
+ export interface ExecUsage {
30
+ inputTokens: number;
31
+ outputTokens: number;
32
+ cacheReadTokens: number;
33
+ cacheWriteTokens: number;
34
+ }
35
+
36
+ /** The four counts from one SDK usage object (snake_case, as the API
37
+ * reports it). The API counts fresh input apart from the cache's; here
38
+ * `inputTokens` is all of it, as it always was on the record, and the
39
+ * cache fields say how much of that the cache served or stored. */
40
+ export function usageFromSdk(u: Record<string, number | undefined>): ExecUsage {
41
+ return {
42
+ inputTokens: (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0),
43
+ outputTokens: u.output_tokens ?? 0,
44
+ cacheReadTokens: u.cache_read_input_tokens ?? 0,
45
+ cacheWriteTokens: u.cache_creation_input_tokens ?? 0,
46
+ };
47
+ }
48
+
28
49
  export interface ExecOutcome {
29
50
  status: "completed" | "failed";
30
51
  result: string | null;
@@ -39,8 +60,12 @@ export interface ExecOutcome {
39
60
  costUsd: number | null;
40
61
  /** Token counts off the SDK's result message. costUsd is priced from
41
62
  * Anthropic's table, which is wrong for a routed model — these are the
42
- * raw numbers a caller with a gateway's own prices can reprice from. */
43
- usage: { inputTokens: number; outputTokens: number } | null;
63
+ * raw numbers a caller with a gateway's own prices can reprice from.
64
+ * `inputTokens` is every input token; `cacheReadTokens` and
65
+ * `cacheWriteTokens` are the part of it the prompt cache served or
66
+ * stored, so a usage page can say how much of a step's context was
67
+ * re-read for a tenth of the price. */
68
+ usage: ExecUsage | null;
44
69
  /** Set only when some turns ran on a different model from the step's — a
45
70
  * sub-agent with its own `model:`. The sum of every turn, each priced at
46
71
  * its own model. `usage` above is one lump and cannot be repriced at the
@@ -94,7 +119,7 @@ export interface ExecOptions {
94
119
  /** Price per token for the model, when the catalogue knows it, so spend
95
120
  * can be counted turn by turn instead of learned at the end. Without it
96
121
  * the step prices its turns from the SDK's own running total. */
97
- price?: { input: number; output: number } | null;
122
+ price?: TokenPrice | null;
98
123
  /** A check the step must pass to count as done: a shell command that must
99
124
  * exit 0, or an eval-style assertion (`contains: x`, `not-contains: x`,
100
125
  * `matches: re`, `file: path`, `judge: sentence`) — see checkVerify. */
@@ -201,35 +226,23 @@ export function knownPrice(model: string | undefined): { input: number; output:
201
226
  return KNOWN_PRICES.find(([re]) => re.test(model))?.[1] ?? null;
202
227
  }
203
228
 
204
- /** Anthropic's cache multipliers, and the shape every gateway that bills
205
- * for caching at all has copied: a write costs a quarter more than fresh
206
- * input, a read a tenth of it. Counting both at the full input rate — as
207
- * this did — makes a long cached prompt read about ten times its price,
208
- * which on a ceiling is not "the honest direction to be wrong in", it is
209
- * a step killed for spending money it never spent. */
210
- const CACHE_WRITE_MULTIPLIER = 1.25;
211
- const CACHE_READ_MULTIPLIER = 0.1;
212
-
213
229
  /**
214
230
  * What one assistant turn cost, from the usage the SDK reports. Pure.
215
231
  *
216
- * A gateway whose cache is priced differently is over- or under-counted
217
- * here by that difference alone; the run record's own cost is repriced
218
- * from the catalogue afterwards and stays the authority on the bill. This
219
- * number exists to stop a runaway step mid-turn, and wants to be close.
232
+ * Cache reads and writes are priced at the model's own cache prices when
233
+ * the catalogue quotes them, else at Anthropic's multipliers (costOf in
234
+ * catalog.ts, the same arithmetic the record is repriced with). Counting
235
+ * both at the full input rate — as this did —
236
+ * makes a long cached prompt read about ten times its price, which on a
237
+ * ceiling is not "the honest direction to be wrong in", it is a step
238
+ * killed for spending money it never spent.
239
+ *
240
+ * The run record's own cost is repriced from the catalogue afterwards and
241
+ * stays the authority on the bill. This number exists to stop a runaway
242
+ * step mid-turn, and wants to be close.
220
243
  */
221
- export function priceTurn(u: Record<string, number | undefined>, price: { input: number; output: number } | null): number {
222
- const p = price ?? FALLBACK_PRICE;
223
- const fresh = u.input_tokens ?? 0;
224
- const written = u.cache_creation_input_tokens ?? 0;
225
- const read = u.cache_read_input_tokens ?? 0;
226
- const outTok = u.output_tokens ?? 0;
227
- return (
228
- fresh * p.input +
229
- written * p.input * CACHE_WRITE_MULTIPLIER +
230
- read * p.input * CACHE_READ_MULTIPLIER +
231
- outTok * p.output
232
- );
244
+ export function priceTurn(u: Record<string, number | undefined>, price: TokenPrice | null): number {
245
+ return costOf(usageFromSdk(u), price ?? FALLBACK_PRICE);
233
246
  }
234
247
 
235
248
  /**
@@ -611,10 +624,10 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
611
624
  // runs its turns on that model, and pricing them at the parent's put a
612
625
  // max sub-agent under a fast parent at a fifth of its cost — against the
613
626
  // ceiling and on the bill.
614
- const turns = new Map<string, { u: Record<string, number | undefined>; price: { input: number; output: number } | null }>();
627
+ const turns = new Map<string, { u: Record<string, number | undefined>; price: TokenPrice | null }>();
615
628
  let anonymousTurns = 0;
616
629
  const specByName = new Map((subagents ?? []).map((s) => [s.name, s]));
617
- const priceOf = (message: unknown): { input: number; output: number } | null => {
630
+ const priceOf = (message: unknown): TokenPrice | null => {
618
631
  const parent = (message as { parent_tool_use_id?: string | null }).parent_tool_use_id;
619
632
  if (!parent) return opts.price ?? null;
620
633
  const declared = specByName.get(delegations.get(parent) ?? "")?.model;
@@ -762,17 +775,10 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
762
775
  costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
763
776
  stopReason = "stop_reason" in message && typeof message.stop_reason === "string" ? message.stop_reason : null;
764
777
  if ("usage" in message && message.usage) {
765
- const u = message.usage as unknown as Record<string, number | undefined>;
766
- usage = {
767
- // Cache traffic is input the provider still bills (at its own
768
- // rates); folding it into the input count over-approximates for
769
- // gateways with cheaper cache reads, which errs on the honest side.
770
- inputTokens:
771
- (u.input_tokens ?? 0) +
772
- (u.cache_creation_input_tokens ?? 0) +
773
- (u.cache_read_input_tokens ?? 0),
774
- outputTokens: u.output_tokens ?? 0,
775
- };
778
+ // Cache reads and writes counted beside the input total: with only
779
+ // the total (until 18 Oct 2026) the hit rate could not be read and
780
+ // the catalogue repriced every cached token at the full rate.
781
+ usage = usageFromSdk(message.usage as unknown as Record<string, number | undefined>);
776
782
  }
777
783
  }
778
784
  }
@@ -790,16 +796,18 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
790
796
  // died — means no total from the SDK. The turns it did take were billed by
791
797
  // the provider all the same, so the step's cost is their sum.
792
798
  if (costUsd === null && turns.size) {
793
- let input = 0;
794
- let output = 0;
799
+ const summed: ExecUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
795
800
  let priced = 0;
796
801
  for (const { u: t, price } of turns.values()) {
797
- input += (t.input_tokens ?? 0) + (t.cache_creation_input_tokens ?? 0) + (t.cache_read_input_tokens ?? 0);
798
- output += t.output_tokens ?? 0;
802
+ const one = usageFromSdk(t);
803
+ summed.inputTokens += one.inputTokens;
804
+ summed.outputTokens += one.outputTokens;
805
+ summed.cacheReadTokens += one.cacheReadTokens;
806
+ summed.cacheWriteTokens += one.cacheWriteTokens;
799
807
  priced += priceTurn(t, price);
800
808
  }
801
809
  costUsd = priced;
802
- usage ??= { inputTokens: input, outputTokens: output };
810
+ usage ??= summed;
803
811
  emit("info", `cost from ${turns.size} model turn${turns.size === 1 ? "" : "s"}: $${priced.toFixed(4)} — the step ended before the model's closing total`);
804
812
  }
805
813
  if (ended === "timeout") {
package/src/store.ts CHANGED
@@ -369,7 +369,14 @@ export function saveWorkspace(
369
369
  files: DeployFile[],
370
370
  /** Attribution for the revision this save records. A deploy passes the
371
371
  * commit, which becomes the revision's id. */
372
- meta: ChangeMeta & { commit?: string | null } = {},
372
+ meta: ChangeMeta & {
373
+ commit?: string | null;
374
+ /** Keep every file that is not editable source — what an agent wrote
375
+ * into its own folder at run time (a browser profile, a PDF it
376
+ * downloaded). A restore sets it: history holds source, so a file it
377
+ * never tracked is not one a restore may take away. */
378
+ keepUntracked?: boolean;
379
+ } = {},
373
380
  ) {
374
381
  if (files.length > 500) throw new Error("too many files");
375
382
  for (const f of files) {
@@ -424,8 +431,6 @@ export function saveWorkspace(
424
431
  // about it. Before this, every deploy silently erased everything the agents
425
432
  // had learned.
426
433
  const shipped = new Set(files.map((f) => path.normalize(f.path)));
427
- const isAgentOwned = (rel: string) => keptOnDeploy(rel, shipped);
428
-
429
434
  // What is there now, for the revision: every editable file's content
430
435
  // before this save replaces the tree.
431
436
  const before = new Map<string, string>();
@@ -439,6 +444,8 @@ export function saveWorkspace(
439
444
  }
440
445
  }
441
446
 
447
+ const isAgentOwned = (rel: string) => keptOnDeploy(rel, shipped) || (meta.keepUntracked === true && !before.has(rel) && !shipped.has(rel));
448
+
442
449
  const snapshot = fs.existsSync(dir) ? fs.mkdtempSync(path.join(dataRoot(), ".keep-")) : null;
443
450
  const preserved: string[] = [];
444
451
  if (snapshot) {
@@ -446,9 +453,13 @@ export function saveWorkspace(
446
453
  const rel = String(entry).split(path.sep).join("/");
447
454
  const from = path.join(dir, rel);
448
455
  try {
449
- if (!fs.statSync(from).isFile() || !isAgentOwned(rel)) continue;
456
+ // lstat, never stat: a link is never followed. An agent folder's link
457
+ // to a file outside the workspace was copied in as that file's
458
+ // contents — on a restore with keepUntracked, any such link
459
+ // (Codex, 18 Oct 2026).
460
+ if (!fs.lstatSync(from).isFile() || !isAgentOwned(rel)) continue;
450
461
  } catch {
451
- continue; // a broken link or a file that vanished mid-deploy
462
+ continue; // a file that vanished mid-deploy
452
463
  }
453
464
  const to = path.join(snapshot, rel);
454
465
  fs.mkdirSync(path.dirname(to), { recursive: true });
@@ -3091,7 +3102,7 @@ export interface StepAttempt {
3091
3102
  * of its own (image slim), before the full go's. */
3092
3103
  status: "completed" | "failed" | "lost";
3093
3104
  costUsd: number | null;
3094
- tokens: { input: number; output: number } | null;
3105
+ tokens: TokenUsage | null;
3095
3106
  computeSecs: number | null;
3096
3107
  startedAt: string;
3097
3108
  finishedAt: string;
@@ -3109,15 +3120,84 @@ export interface StepAttempt {
3109
3120
  * billing reads null as zero, not as "unknown, charge something". */
3110
3121
  export function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" | "tokens" | "computeSecs"> {
3111
3122
  const costs = tries.map((t) => t.costUsd).filter((c): c is number => typeof c === "number");
3112
- const toks = tries.map((t) => t.tokens).filter((t): t is { input: number; output: number } => !!t);
3123
+ const toks = tries.map((t) => t.tokens).filter((t): t is TokenUsage => !!t);
3113
3124
  const secs = tries.map((t) => t.computeSecs).filter((c): c is number => typeof c === "number");
3114
3125
  return {
3115
3126
  costUsd: costs.length ? costs.reduce((a, b) => a + b, 0) : null,
3116
- tokens: toks.length ? toks.reduce((a, b) => ({ input: a.input + b.input, output: a.output + b.output }), { input: 0, output: 0 }) : null,
3127
+ tokens: toks.length ? sumTokens(toks) : null,
3117
3128
  computeSecs: secs.length ? secs.reduce((a, b) => a + b, 0) : null,
3118
3129
  };
3119
3130
  }
3120
3131
 
3132
+ /**
3133
+ * The token counts behind a step's cost. `input` is every input token the
3134
+ * model saw; `cacheRead` and `cacheWrite` are the part of it the prompt
3135
+ * cache served or stored — a read costs a tenth of fresh input, and a usage
3136
+ * page wants to say how much of a step's context was re-read rather than
3137
+ * re-sent. Steps recorded before 18 Oct 2026 have no cache fields: their
3138
+ * `input` is the same total, but how much of it was cached is unknown.
3139
+ */
3140
+ export interface TokenUsage {
3141
+ input: number;
3142
+ output: number;
3143
+ cacheRead?: number;
3144
+ cacheWrite?: number;
3145
+ }
3146
+
3147
+ /** The record's shape from what a step reports (ExecOutcome.usage). */
3148
+ export function tokensOf(usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number } | null | undefined): TokenUsage | null {
3149
+ if (!usage) return null;
3150
+ return {
3151
+ input: usage.inputTokens,
3152
+ output: usage.outputTokens,
3153
+ cacheRead: usage.cacheReadTokens ?? 0,
3154
+ cacheWrite: usage.cacheWriteTokens ?? 0,
3155
+ };
3156
+ }
3157
+
3158
+ /** Field-by-field sum; the cache fields are present in the result whenever
3159
+ * any input had them, so a run of old and new steps still reads whole. */
3160
+ export function sumTokens(list: readonly TokenUsage[]): TokenUsage {
3161
+ const out: TokenUsage = { input: 0, output: 0 };
3162
+ for (const t of list) {
3163
+ out.input += t.input;
3164
+ out.output += t.output;
3165
+ if (t.cacheRead !== undefined || t.cacheWrite !== undefined) {
3166
+ out.cacheRead = (out.cacheRead ?? 0) + (t.cacheRead ?? 0);
3167
+ out.cacheWrite = (out.cacheWrite ?? 0) + (t.cacheWrite ?? 0);
3168
+ }
3169
+ }
3170
+ return out;
3171
+ }
3172
+
3173
+ /** The share of all input that the prompt cache served, 0–1; null when
3174
+ * there was no input, or the record predates the split and cannot say. */
3175
+ export function cacheHitRate(t: TokenUsage | null | undefined): number | null {
3176
+ if (!t || t.cacheRead === undefined) return null;
3177
+ return t.input > 0 ? Math.min(1, t.cacheRead / t.input) : null;
3178
+ }
3179
+
3180
+ /** The cache share over several counts, taking only those that report the
3181
+ * split: a step recorded before it holds its cache traffic inside `input`,
3182
+ * and counting that as uncached would understate the rate. The rule the
3183
+ * Usage page and the CLI's totals follow too. */
3184
+ export function measuredHitRate(list: readonly (TokenUsage | null | undefined)[]): number | null {
3185
+ let read = 0;
3186
+ let all = 0;
3187
+ for (const t of list) {
3188
+ if (!t || t.cacheRead === undefined) continue;
3189
+ read += t.cacheRead;
3190
+ all += t.input;
3191
+ }
3192
+ return all > 0 ? read / all : null;
3193
+ }
3194
+
3195
+ /** A run's token counts, summed over its steps; null when no step reported any. */
3196
+ export function runTokens(run: { steps: readonly { tokens?: TokenUsage | null }[] }): TokenUsage | null {
3197
+ const toks = run.steps.map((s) => s.tokens).filter((t): t is TokenUsage => !!t);
3198
+ return toks.length ? sumTokens(toks) : null;
3199
+ }
3200
+
3121
3201
  export interface StepRecord {
3122
3202
  agent: string;
3123
3203
  instruction: string;
@@ -3296,7 +3376,7 @@ export interface StepRecord {
3296
3376
  /** Token counts behind costUsd, kept so usage reports can say "how many"
3297
3377
  * and not only "how much" — the price of a token changes, the count is
3298
3378
  * the fact. Absent on steps recorded before this existed. */
3299
- tokens?: { input: number; output: number } | null;
3379
+ tokens?: TokenUsage | null;
3300
3380
  /** What the sandbox actually touched, against what it reserved: CPU
3301
3381
  * busy-seconds and peak memory from the pod's own cgroup, bytes on the
3302
3382
  * wire from /proc/net/dev. Null per-field where the kernel interface
@@ -3577,7 +3657,7 @@ export interface RunSummaryStep {
3577
3657
  group: number;
3578
3658
  status: StepRecord["status"];
3579
3659
  costUsd: number | null;
3580
- tokens?: { input: number; output: number } | null;
3660
+ tokens?: TokenUsage | null;
3581
3661
  computeSecs?: number | null;
3582
3662
  skipReason?: string;
3583
3663
  /** The last error event, for a failed step — what runFailure reads. */
package/src/tool-loop.ts CHANGED
@@ -34,9 +34,12 @@ export interface LoopTool {
34
34
  }
35
35
 
36
36
  export interface LoopUsage {
37
- /** Everything sent, cache reads and writes included. */
37
+ /** Everything sent, cache reads and writes included; the two cache
38
+ * fields say how much of it the prompt cache served or stored. */
38
39
  inputTokens: number;
39
40
  outputTokens: number;
41
+ cacheReadTokens: number;
42
+ cacheWriteTokens: number;
40
43
  costUsd: number;
41
44
  }
42
45
 
@@ -221,6 +224,8 @@ export async function runToolLoop(o: LoopOptions): Promise<LoopResult> {
221
224
  usage = {
222
225
  inputTokens: models.reduce((n, m) => n + (m.inputTokens ?? 0) + (m.cacheReadInputTokens ?? 0) + (m.cacheCreationInputTokens ?? 0), 0),
223
226
  outputTokens: models.reduce((n, m) => n + (m.outputTokens ?? 0), 0),
227
+ cacheReadTokens: models.reduce((n, m) => n + (m.cacheReadInputTokens ?? 0), 0),
228
+ cacheWriteTokens: models.reduce((n, m) => n + (m.cacheCreationInputTokens ?? 0), 0),
224
229
  costUsd,
225
230
  };
226
231
  if (message.subtype === "success") {
package/src/translator.ts CHANGED
@@ -22,7 +22,9 @@
22
22
  // and their results, streaming — and drops what a Chat-Completions endpoint
23
23
  // has no word for (cache_control, top_k, server-side tools, thinking on
24
24
  // endpoints that lack reasoning_effort). Anything dropped is said once in
25
- // the run trace. The translation is pure functions over JSON so the tests
25
+ // the run trace. Those endpoints cache prompt prefixes on their own side;
26
+ // the cached share they report comes back as cache_read_input_tokens, so a
27
+ // step's record says how much of its context was re-read. The translation is pure functions over JSON so the tests
26
28
  // can drive every mapping without a network; the server around them is a
27
29
  // few dozen lines.
28
30
 
@@ -37,6 +39,34 @@ import { captureRefusalHeaders, isRefusalStatus, refusalLine } from "./refusal.t
37
39
 
38
40
  type Json = Record<string, unknown>;
39
41
 
42
+ /**
43
+ * An OpenAI-shaped usage object as Anthropic's: fresh input, cached input,
44
+ * output. Both OpenAI shapes count the cached part INSIDE the prompt total
45
+ * (Chat's prompt_tokens_details.cached_tokens, Responses'
46
+ * input_tokens_details.cached_tokens; DeepSeek says prompt_cache_hit_tokens),
47
+ * so it is taken out of input here or the step would count it twice.
48
+ */
49
+ export function anthropicUsage(u: Json | undefined, wire: TranslatorWire): { input: number; cached: number; output: number } {
50
+ if (!u) return { input: 0, cached: 0, output: 0 };
51
+ const total = Number((wire === "chat" ? u.prompt_tokens : u.input_tokens) ?? 0);
52
+ const details = (wire === "chat" ? u.prompt_tokens_details : u.input_tokens_details) as Json | undefined;
53
+ const raw = Number(details?.cached_tokens ?? u.prompt_cache_hit_tokens ?? 0);
54
+ const cached = Number.isFinite(raw) ? Math.max(0, Math.min(raw, total)) : 0;
55
+ return {
56
+ input: total - cached,
57
+ cached,
58
+ output: Number((wire === "chat" ? u.completion_tokens : u.output_tokens) ?? 0),
59
+ };
60
+ }
61
+
62
+ function usageBlock(u: { input: number; cached: number; output: number } | null): Json {
63
+ return {
64
+ input_tokens: u?.input ?? 0,
65
+ output_tokens: u?.output ?? 0,
66
+ ...(u && u.cached > 0 ? { cache_read_input_tokens: u.cached } : {}),
67
+ };
68
+ }
69
+
40
70
  export type TranslatorWire = "chat" | "responses";
41
71
 
42
72
  export interface TranslatorSpec {
@@ -493,10 +523,7 @@ export function fromChatCompletion(res: Json, requestedModel: string, stopSequen
493
523
  content,
494
524
  stop_reason: stop.reason,
495
525
  stop_sequence: stop.sequence,
496
- usage: {
497
- input_tokens: Number(usage.prompt_tokens ?? 0),
498
- output_tokens: Number(usage.completion_tokens ?? 0),
499
- },
526
+ usage: usageBlock(anthropicUsage(usage, "chat")),
500
527
  };
501
528
  }
502
529
 
@@ -515,7 +542,7 @@ export class StreamTranslator {
515
542
  private tools = new Map<number, { block: number; argsSeen: boolean }>();
516
543
  /** The last choice's stop fields: finish_reason and any matched stop. */
517
544
  private finish: Json = {};
518
- private usage: { input: number; output: number } | null = null;
545
+ private usage: { input: number; cached: number; output: number } | null = null;
519
546
  private id = `msg_${crypto.randomBytes(8).toString("hex")}`;
520
547
  private readonly model: string;
521
548
  private readonly stops: string[];
@@ -561,7 +588,7 @@ export class StreamTranslator {
561
588
  const out = this.start(chunk);
562
589
  const u = chunk.usage as Json | undefined;
563
590
  if (u && (u.prompt_tokens !== undefined || u.completion_tokens !== undefined)) {
564
- this.usage = { input: Number(u.prompt_tokens ?? 0), output: Number(u.completion_tokens ?? 0) };
591
+ this.usage = anthropicUsage(u, "chat");
565
592
  }
566
593
  const choice = ((chunk.choices as Json[] | undefined) ?? [])[0];
567
594
  if (!choice) return out;
@@ -650,7 +677,7 @@ export class StreamTranslator {
650
677
  this.event("message_delta", {
651
678
  type: "message_delta",
652
679
  delta: { stop_reason: stop.reason, stop_sequence: stop.sequence },
653
- usage: { input_tokens: this.usage?.input ?? 0, output_tokens: this.usage?.output ?? 0 },
680
+ usage: usageBlock(this.usage),
654
681
  }),
655
682
  );
656
683
  out.push(this.event("message_stop", { type: "message_stop" }));
@@ -691,10 +718,7 @@ export function fromResponses(res: Json, requestedModel: string): Json {
691
718
  content,
692
719
  stop_reason: stop,
693
720
  stop_sequence: null,
694
- usage: {
695
- input_tokens: Number(usage.input_tokens ?? 0),
696
- output_tokens: Number(usage.output_tokens ?? 0),
697
- },
721
+ usage: usageBlock(anthropicUsage(usage, "responses")),
698
722
  };
699
723
  }
700
724
 
@@ -710,7 +734,7 @@ export class ResponsesStreamTranslator {
710
734
  private nextIndex = 0;
711
735
  /** Responses output_index → Anthropic block index and kind. */
712
736
  private blocks = new Map<number, { block: number; kind: "text" | "tool"; argsSeen: boolean }>();
713
- private usage: { input: number; output: number } | null = null;
737
+ private usage: { input: number; cached: number; output: number } | null = null;
714
738
  private incomplete: string | null = null;
715
739
  private sawTool = false;
716
740
  private sawRefusal = false;
@@ -812,7 +836,7 @@ export class ResponsesStreamTranslator {
812
836
  if (type === "response.completed" || type === "response.incomplete" || type === "response.failed") {
813
837
  const r = (ev.response as Json | undefined) ?? {};
814
838
  const u = r.usage as Json | undefined;
815
- if (u) this.usage = { input: Number(u.input_tokens ?? 0), output: Number(u.output_tokens ?? 0) };
839
+ if (u) this.usage = anthropicUsage(u, "responses");
816
840
  const reason = (r.incomplete_details as Json | undefined)?.reason;
817
841
  if (typeof reason === "string") this.incomplete = reason;
818
842
  return out;
@@ -834,7 +858,7 @@ export class ResponsesStreamTranslator {
834
858
  this.event("message_delta", {
835
859
  type: "message_delta",
836
860
  delta: { stop_reason: reason, stop_sequence: null },
837
- usage: { input_tokens: this.usage?.input ?? 0, output_tokens: this.usage?.output ?? 0 },
861
+ usage: usageBlock(this.usage),
838
862
  }),
839
863
  );
840
864
  out.push(this.event("message_stop", { type: "message_stop" }));