@foldrun/core 2.0.0 → 2.1.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/dist/src/catalog.d.ts +7 -0
- package/dist/src/catalog.js +16 -1
- package/dist/src/deploy.d.ts +6 -1
- package/dist/src/deploy.js +8 -5
- package/dist/src/model-credential.js +1 -1
- package/dist/src/model-env.js +1 -1
- package/dist/src/run-container.d.ts +2 -5
- package/dist/src/run-container.js +8 -1
- package/dist/src/runner.d.ts +2 -0
- package/dist/src/runner.js +11 -11
- package/dist/src/step-exec.d.ts +23 -5
- package/dist/src/step-exec.js +30 -23
- package/dist/src/store.d.ts +46 -12
- package/dist/src/store.js +60 -4
- package/dist/src/tool-loop.d.ts +4 -1
- package/dist/src/tool-loop.js +2 -0
- package/dist/src/translator.d.ts +12 -0
- package/dist/src/translator.js +36 -13
- package/package.json +1 -1
- package/src/catalog.ts +18 -2
- package/src/deploy.ts +11 -4
- package/src/model-credential.ts +1 -1
- package/src/model-env.ts +1 -1
- package/src/run-container.ts +10 -3
- package/src/runner.ts +12 -11
- package/src/step-exec.ts +44 -27
- package/src/store.ts +90 -10
- package/src/tool-loop.ts +6 -1
- package/src/translator.ts +39 -15
package/dist/src/translator.js
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.
|
|
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
|
import http from "node:http";
|
|
@@ -31,6 +33,33 @@ import { PROTECTED_PARAMS } from "./providers.js";
|
|
|
31
33
|
import { viaEgress } from "./egress.js";
|
|
32
34
|
import { trimSlashes } from "./paths.js";
|
|
33
35
|
import { captureRefusalHeaders, isRefusalStatus, refusalLine } from "./refusal.js";
|
|
36
|
+
/**
|
|
37
|
+
* An OpenAI-shaped usage object as Anthropic's: fresh input, cached input,
|
|
38
|
+
* output. Both OpenAI shapes count the cached part INSIDE the prompt total
|
|
39
|
+
* (Chat's prompt_tokens_details.cached_tokens, Responses'
|
|
40
|
+
* input_tokens_details.cached_tokens; DeepSeek says prompt_cache_hit_tokens),
|
|
41
|
+
* so it is taken out of input here or the step would count it twice.
|
|
42
|
+
*/
|
|
43
|
+
export function anthropicUsage(u, wire) {
|
|
44
|
+
if (!u)
|
|
45
|
+
return { input: 0, cached: 0, output: 0 };
|
|
46
|
+
const total = Number((wire === "chat" ? u.prompt_tokens : u.input_tokens) ?? 0);
|
|
47
|
+
const details = (wire === "chat" ? u.prompt_tokens_details : u.input_tokens_details);
|
|
48
|
+
const raw = Number(details?.cached_tokens ?? u.prompt_cache_hit_tokens ?? 0);
|
|
49
|
+
const cached = Number.isFinite(raw) ? Math.max(0, Math.min(raw, total)) : 0;
|
|
50
|
+
return {
|
|
51
|
+
input: total - cached,
|
|
52
|
+
cached,
|
|
53
|
+
output: Number((wire === "chat" ? u.completion_tokens : u.output_tokens) ?? 0),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function usageBlock(u) {
|
|
57
|
+
return {
|
|
58
|
+
input_tokens: u?.input ?? 0,
|
|
59
|
+
output_tokens: u?.output ?? 0,
|
|
60
|
+
...(u && u.cached > 0 ? { cache_read_input_tokens: u.cached } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
34
63
|
function dropped() {
|
|
35
64
|
const seen = new Set();
|
|
36
65
|
return {
|
|
@@ -466,10 +495,7 @@ export function fromChatCompletion(res, requestedModel, stopSequences) {
|
|
|
466
495
|
content,
|
|
467
496
|
stop_reason: stop.reason,
|
|
468
497
|
stop_sequence: stop.sequence,
|
|
469
|
-
usage:
|
|
470
|
-
input_tokens: Number(usage.prompt_tokens ?? 0),
|
|
471
|
-
output_tokens: Number(usage.completion_tokens ?? 0),
|
|
472
|
-
},
|
|
498
|
+
usage: usageBlock(anthropicUsage(usage, "chat")),
|
|
473
499
|
};
|
|
474
500
|
}
|
|
475
501
|
/**
|
|
@@ -532,7 +558,7 @@ export class StreamTranslator {
|
|
|
532
558
|
const out = this.start(chunk);
|
|
533
559
|
const u = chunk.usage;
|
|
534
560
|
if (u && (u.prompt_tokens !== undefined || u.completion_tokens !== undefined)) {
|
|
535
|
-
this.usage =
|
|
561
|
+
this.usage = anthropicUsage(u, "chat");
|
|
536
562
|
}
|
|
537
563
|
const choice = (chunk.choices ?? [])[0];
|
|
538
564
|
if (!choice)
|
|
@@ -609,7 +635,7 @@ export class StreamTranslator {
|
|
|
609
635
|
out.push(this.event("message_delta", {
|
|
610
636
|
type: "message_delta",
|
|
611
637
|
delta: { stop_reason: stop.reason, stop_sequence: stop.sequence },
|
|
612
|
-
usage:
|
|
638
|
+
usage: usageBlock(this.usage),
|
|
613
639
|
}));
|
|
614
640
|
out.push(this.event("message_stop", { type: "message_stop" }));
|
|
615
641
|
return out;
|
|
@@ -648,10 +674,7 @@ export function fromResponses(res, requestedModel) {
|
|
|
648
674
|
content,
|
|
649
675
|
stop_reason: stop,
|
|
650
676
|
stop_sequence: null,
|
|
651
|
-
usage:
|
|
652
|
-
input_tokens: Number(usage.input_tokens ?? 0),
|
|
653
|
-
output_tokens: Number(usage.output_tokens ?? 0),
|
|
654
|
-
},
|
|
677
|
+
usage: usageBlock(anthropicUsage(usage, "responses")),
|
|
655
678
|
};
|
|
656
679
|
}
|
|
657
680
|
/**
|
|
@@ -768,7 +791,7 @@ export class ResponsesStreamTranslator {
|
|
|
768
791
|
const r = ev.response ?? {};
|
|
769
792
|
const u = r.usage;
|
|
770
793
|
if (u)
|
|
771
|
-
this.usage =
|
|
794
|
+
this.usage = anthropicUsage(u, "responses");
|
|
772
795
|
const reason = r.incomplete_details?.reason;
|
|
773
796
|
if (typeof reason === "string")
|
|
774
797
|
this.incomplete = reason;
|
|
@@ -790,7 +813,7 @@ export class ResponsesStreamTranslator {
|
|
|
790
813
|
out.push(this.event("message_delta", {
|
|
791
814
|
type: "message_delta",
|
|
792
815
|
delta: { stop_reason: reason, stop_sequence: null },
|
|
793
|
-
usage:
|
|
816
|
+
usage: usageBlock(this.usage),
|
|
794
817
|
}));
|
|
795
818
|
out.push(this.event("message_stop", { type: "message_stop" }));
|
|
796
819
|
return out;
|
package/package.json
CHANGED
package/src/catalog.ts
CHANGED
|
@@ -245,13 +245,29 @@ export function clampEffort(
|
|
|
245
245
|
export function catalogCost(
|
|
246
246
|
catalog: Catalog | null,
|
|
247
247
|
modelId: string,
|
|
248
|
-
usage: { inputTokens: number; outputTokens: number },
|
|
248
|
+
usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number },
|
|
249
249
|
): number | null {
|
|
250
250
|
const m = findModel(catalog, modelId);
|
|
251
251
|
if (!m || m.promptPrice == null || m.completionPrice == null) return null;
|
|
252
|
-
|
|
252
|
+
// A catalogue quotes one input price; cache traffic is priced off it with
|
|
253
|
+
// the multipliers every gateway that bills for caching has copied from
|
|
254
|
+
// Anthropic. Before the counts were split (18 Oct 2026) every cached
|
|
255
|
+
// token was billed here at the full rate — a long cached step cost ten
|
|
256
|
+
// times its price on the ledger.
|
|
257
|
+
// inputTokens is all input; the cache fields are parts of it.
|
|
258
|
+
const read = usage.cacheReadTokens ?? 0;
|
|
259
|
+
const written = usage.cacheWriteTokens ?? 0;
|
|
260
|
+
const fresh = Math.max(0, usage.inputTokens - read - written);
|
|
261
|
+
const input = fresh + written * CACHE_WRITE_MULTIPLIER + read * CACHE_READ_MULTIPLIER;
|
|
262
|
+
return input * m.promptPrice + usage.outputTokens * m.completionPrice;
|
|
253
263
|
}
|
|
254
264
|
|
|
265
|
+
/** Anthropic's cache multipliers over the input price: a write costs a
|
|
266
|
+
* quarter more than fresh input, a read a tenth of it. One source for the
|
|
267
|
+
* mid-step ceiling (priceTurn) and the record's repricing (catalogCost). */
|
|
268
|
+
export const CACHE_WRITE_MULTIPLIER = 1.25;
|
|
269
|
+
export const CACHE_READ_MULTIPLIER = 0.1;
|
|
270
|
+
|
|
255
271
|
/** Catalogue entries as completion items: price per MTok, context, and the
|
|
256
272
|
* one capability that decides agent-worthiness. Tool-capable models first —
|
|
257
273
|
* on an agent platform they are almost always what the author wants. */
|
package/src/deploy.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
keptOnDeploy,
|
|
27
27
|
workspaceDir,
|
|
28
28
|
listRunSummaries,
|
|
29
|
+
listWorkspaceFiles,
|
|
29
30
|
parseFlow,
|
|
30
31
|
parseToolDef,
|
|
31
32
|
WORKSPACE_DIRS,
|
|
@@ -460,7 +461,7 @@ export function runsInFlight(tenant: string, workspace: string): string[] {
|
|
|
460
461
|
}
|
|
461
462
|
|
|
462
463
|
/** What a deploy would do, without doing it. */
|
|
463
|
-
export function planDeploy(tenant: string, workspace: string, files: DeployFile[]): DeployPlan {
|
|
464
|
+
export function planDeploy(tenant: string, workspace: string, files: DeployFile[], opts: { keepUntracked?: boolean } = {}): DeployPlan {
|
|
464
465
|
const dir = workspaceDir(tenant, workspace);
|
|
465
466
|
const shipped = new Map(files.map((f) => [path.normalize(f.path), f.content]));
|
|
466
467
|
|
|
@@ -498,7 +499,10 @@ export function planDeploy(tenant: string, workspace: string, files: DeployFile[
|
|
|
498
499
|
// memory, the platform's own files. Reporting these as removals would be
|
|
499
500
|
// a lie, and the alarming kind; one predicate serves both.
|
|
500
501
|
const shippedPaths = new Set(shipped.keys());
|
|
501
|
-
|
|
502
|
+
// keepUntracked (a restore): what is not editable source stays, as
|
|
503
|
+
// saveWorkspace keeps it — see its meta.keepUntracked.
|
|
504
|
+
const tracked = opts.keepUntracked ? new Set(listWorkspaceFiles(tenant, workspace)) : null;
|
|
505
|
+
const removed = [...present].filter((rel) => !shipped.has(rel) && !keptOnDeploy(rel, shippedPaths) && (!tracked || tracked.has(rel)));
|
|
502
506
|
|
|
503
507
|
return {
|
|
504
508
|
files,
|
|
@@ -528,6 +532,9 @@ export interface DeployOptions {
|
|
|
528
532
|
* no window between the check and the swap. Absent: no check, as before.
|
|
529
533
|
*/
|
|
530
534
|
expectRemoved?: string[];
|
|
535
|
+
/** Keep what is not editable source (planDeploy, saveWorkspace): a
|
|
536
|
+
* restore takes history's tree, and history tracks source only. */
|
|
537
|
+
keepUntracked?: boolean;
|
|
531
538
|
}
|
|
532
539
|
|
|
533
540
|
/**
|
|
@@ -547,7 +554,7 @@ export function deployWorkspace(
|
|
|
547
554
|
assertSafeName(tenant, "tenant");
|
|
548
555
|
assertSafeName(workspace, "workspace");
|
|
549
556
|
|
|
550
|
-
const plan = planDeploy(tenant, workspace, files);
|
|
557
|
+
const plan = planDeploy(tenant, workspace, files, { keepUntracked: opts.keepUntracked });
|
|
551
558
|
const blocked = plan.blockedBy.length > 0 && !opts.force;
|
|
552
559
|
if (plan.issues.length > 0 || blocked) {
|
|
553
560
|
return { ...plan, applied: false, commit: null, preserved: 0 };
|
|
@@ -560,7 +567,7 @@ export function deployWorkspace(
|
|
|
560
567
|
}
|
|
561
568
|
}
|
|
562
569
|
|
|
563
|
-
const { preserved } = saveWorkspace(tenant, workspace, files, { commit: opts.commit ?? null, by: opts.by ?? "deploy", message: opts.message });
|
|
570
|
+
const { preserved } = saveWorkspace(tenant, workspace, files, { commit: opts.commit ?? null, by: opts.by ?? "deploy", message: opts.message, keepUntracked: opts.keepUntracked });
|
|
564
571
|
const commit = opts.commit ?? null;
|
|
565
572
|
if (commit) writeDeployedCommit(tenant, workspace, commit);
|
|
566
573
|
return { ...plan, applied: true, commit, preserved }; // saveWorkspace told the platform
|
package/src/model-credential.ts
CHANGED
|
@@ -75,7 +75,7 @@ export function credentialLine(c: ModelCredential): { type: "info" | "error"; te
|
|
|
75
75
|
//
|
|
76
76
|
// An account brings its own model key without writing a provider: block:
|
|
77
77
|
// three secrets in the ACCOUNT vault (set from Settings → Model, the API's
|
|
78
|
-
// /api/account/model, or `foldrun model set`). A provider: block at any
|
|
78
|
+
// /api/account/model, or `foldrun account model set`). A provider: block at any
|
|
79
79
|
// level still wins; this is what a step falls to when none names one, before
|
|
80
80
|
// the platform's own credential.
|
|
81
81
|
|
package/src/model-env.ts
CHANGED
|
@@ -23,7 +23,7 @@ export class ModelKeyError extends Error {
|
|
|
23
23
|
|
|
24
24
|
export const NO_MODEL_KEY =
|
|
25
25
|
"no model key: this account runs on its own API key, and none is set — add one in Settings → Model " +
|
|
26
|
-
"(or `foldrun model set <provider> --key …`), or write a provider: block in AGENTS.md";
|
|
26
|
+
"(or `foldrun account model set <provider> --key …`), or write a provider: block in AGENTS.md";
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* `platform` is a function: the caller's own way to build the platform's env
|
package/src/run-container.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
27
27
|
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
28
28
|
import { isPlatformPath, type ApiSpec, type Effort } from "./store.ts";
|
|
29
29
|
import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.ts";
|
|
30
|
-
import type { EventExtra } from "./step-exec.ts";
|
|
30
|
+
import type { EventExtra, ExecUsage } from "./step-exec.ts";
|
|
31
31
|
import { isFileValue, fileContent } from "./secrets.ts";
|
|
32
32
|
import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKET, type RuntimeSpec } from "./runtime.ts";
|
|
33
33
|
import { egressCaSh, type NetworkLevel } from "./network.ts";
|
|
@@ -184,7 +184,7 @@ export interface ContainerStepOutcome {
|
|
|
184
184
|
/** The parsed JSON of an `output: json` step, crossing back as a value. */
|
|
185
185
|
data?: unknown;
|
|
186
186
|
costUsd: number | null;
|
|
187
|
-
usage?:
|
|
187
|
+
usage?: ExecUsage | null;
|
|
188
188
|
/** Every turn priced at its own model, when sub-agents ran on others —
|
|
189
189
|
* see ExecOutcome.turnsCostUsd. */
|
|
190
190
|
turnsCostUsd?: number;
|
|
@@ -1216,7 +1216,14 @@ export function parseDriverLine(
|
|
|
1216
1216
|
parsed.usage &&
|
|
1217
1217
|
typeof parsed.usage.inputTokens === "number" &&
|
|
1218
1218
|
typeof parsed.usage.outputTokens === "number"
|
|
1219
|
-
? {
|
|
1219
|
+
? {
|
|
1220
|
+
inputTokens: parsed.usage.inputTokens,
|
|
1221
|
+
outputTokens: parsed.usage.outputTokens,
|
|
1222
|
+
// A driver from before the split sends neither; zero, not
|
|
1223
|
+
// unknown, keeps the record's shape one thing.
|
|
1224
|
+
cacheReadTokens: typeof parsed.usage.cacheReadTokens === "number" ? parsed.usage.cacheReadTokens : 0,
|
|
1225
|
+
cacheWriteTokens: typeof parsed.usage.cacheWriteTokens === "number" ? parsed.usage.cacheWriteTokens : 0,
|
|
1226
|
+
}
|
|
1220
1227
|
: null,
|
|
1221
1228
|
res:
|
|
1222
1229
|
parsed.res && typeof parsed.res === "object"
|
package/src/runner.ts
CHANGED
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
type StepRecord,
|
|
76
76
|
type StepAttempt,
|
|
77
77
|
sumAttempts,
|
|
78
|
+
tokensOf,
|
|
78
79
|
parseSchemaText,
|
|
79
80
|
STORAGE_DIR,
|
|
80
81
|
adoptLegacyVersionKey,
|
|
@@ -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
|
-
|
|
2068
|
-
|
|
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();
|
|
@@ -2828,7 +2833,7 @@ async function runStep(
|
|
|
2828
2833
|
step.podLostTry = {
|
|
2829
2834
|
status: "lost",
|
|
2830
2835
|
costUsd: repriced(catalog, wireModel, first.usage ?? null, first.costUsd, push, first.turnsCostUsd),
|
|
2831
|
-
tokens: first.usage
|
|
2836
|
+
tokens: tokensOf(first.usage),
|
|
2832
2837
|
computeSecs: first.timing ? Math.round(first.timing.totalMs) / 1000 : null,
|
|
2833
2838
|
finishedAt: new Date().toISOString(),
|
|
2834
2839
|
error: decision.note.slice(0, 500),
|
|
@@ -2872,9 +2877,7 @@ async function runStep(
|
|
|
2872
2877
|
if (outcome.status === "failed" && outcome.reason) push("error", `sandbox ended: ${outcome.reason}`);
|
|
2873
2878
|
if (outcome.data !== undefined) step.data = redactData(outcome.data);
|
|
2874
2879
|
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;
|
|
2880
|
+
step.tokens = tokensOf(outcome.usage);
|
|
2878
2881
|
step.actual = outcome.res ?? null;
|
|
2879
2882
|
if (outcome.res?.peakMemBytes || outcome.res?.rxBytes) {
|
|
2880
2883
|
const mb = (n: number | null) => (n === null ? "?" : (n / 1024 / 1024).toFixed(0));
|
|
@@ -3076,9 +3079,7 @@ async function runStep(
|
|
|
3076
3079
|
const consultCost = consultTools.drainCost();
|
|
3077
3080
|
const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push, outcome.turnsCostUsd);
|
|
3078
3081
|
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;
|
|
3082
|
+
step.tokens = tokensOf(outcome.usage);
|
|
3082
3083
|
for (const line of apiTools.drainLog()) push("info", `api: ${line}`);
|
|
3083
3084
|
for (const line of scriptTools.drainLog()) push("info", `script: ${line}`);
|
|
3084
3085
|
}
|
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 { CACHE_READ_MULTIPLIER, CACHE_WRITE_MULTIPLIER } 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
|
-
|
|
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
|
|
@@ -201,18 +226,15 @@ 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
|
*
|
|
232
|
+
* Cache reads and writes are priced at Anthropic's multipliers
|
|
233
|
+
* (catalog.ts). Counting both at the full input rate — as this did —
|
|
234
|
+
* makes a long cached prompt read about ten times its price, which on a
|
|
235
|
+
* ceiling is not "the honest direction to be wrong in", it is a step
|
|
236
|
+
* killed for spending money it never spent.
|
|
237
|
+
*
|
|
216
238
|
* A gateway whose cache is priced differently is over- or under-counted
|
|
217
239
|
* here by that difference alone; the run record's own cost is repriced
|
|
218
240
|
* from the catalogue afterwards and stays the authority on the bill. This
|
|
@@ -762,17 +784,10 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
762
784
|
costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
|
|
763
785
|
stopReason = "stop_reason" in message && typeof message.stop_reason === "string" ? message.stop_reason : null;
|
|
764
786
|
if ("usage" in message && message.usage) {
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
};
|
|
787
|
+
// Cache reads and writes counted beside the input total: with only
|
|
788
|
+
// the total (until 18 Oct 2026) the hit rate could not be read and
|
|
789
|
+
// the catalogue repriced every cached token at the full rate.
|
|
790
|
+
usage = usageFromSdk(message.usage as unknown as Record<string, number | undefined>);
|
|
776
791
|
}
|
|
777
792
|
}
|
|
778
793
|
}
|
|
@@ -790,16 +805,18 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
790
805
|
// died — means no total from the SDK. The turns it did take were billed by
|
|
791
806
|
// the provider all the same, so the step's cost is their sum.
|
|
792
807
|
if (costUsd === null && turns.size) {
|
|
793
|
-
|
|
794
|
-
let output = 0;
|
|
808
|
+
const summed: ExecUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
|
795
809
|
let priced = 0;
|
|
796
810
|
for (const { u: t, price } of turns.values()) {
|
|
797
|
-
|
|
798
|
-
|
|
811
|
+
const one = usageFromSdk(t);
|
|
812
|
+
summed.inputTokens += one.inputTokens;
|
|
813
|
+
summed.outputTokens += one.outputTokens;
|
|
814
|
+
summed.cacheReadTokens += one.cacheReadTokens;
|
|
815
|
+
summed.cacheWriteTokens += one.cacheWriteTokens;
|
|
799
816
|
priced += priceTurn(t, price);
|
|
800
817
|
}
|
|
801
818
|
costUsd = priced;
|
|
802
|
-
usage ??=
|
|
819
|
+
usage ??= summed;
|
|
803
820
|
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
821
|
}
|
|
805
822
|
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 & {
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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?:
|
|
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?:
|
|
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") {
|