@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/dist/src/catalog.d.ts +40 -0
- package/dist/src/catalog.js +45 -3
- 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 +4 -9
- package/dist/src/run-container.js +8 -1
- package/dist/src/runner.d.ts +2 -0
- package/dist/src/runner.js +13 -16
- package/dist/src/step-exec.d.ts +31 -17
- package/dist/src/step-exec.js +36 -36
- 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 +68 -4
- 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 +12 -4
- package/src/runner.ts +15 -17
- package/src/step-exec.ts +54 -46
- package/src/store.ts +90 -10
- package/src/tool-loop.ts +6 -1
- package/src/translator.ts +39 -15
package/dist/src/store.d.ts
CHANGED
|
@@ -125,6 +125,11 @@ export declare function saveWorkspace(tenant: string, workspace: string, files:
|
|
|
125
125
|
* commit, which becomes the revision's id. */
|
|
126
126
|
meta?: ChangeMeta & {
|
|
127
127
|
commit?: string | null;
|
|
128
|
+
/** Keep every file that is not editable source — what an agent wrote
|
|
129
|
+
* into its own folder at run time (a browser profile, a PDF it
|
|
130
|
+
* downloaded). A restore sets it: history holds source, so a file it
|
|
131
|
+
* never tracked is not one a restore may take away. */
|
|
132
|
+
keepUntracked?: boolean;
|
|
128
133
|
}): {
|
|
129
134
|
preserved: number;
|
|
130
135
|
};
|
|
@@ -948,10 +953,7 @@ export interface StepAttempt {
|
|
|
948
953
|
* of its own (image slim), before the full go's. */
|
|
949
954
|
status: "completed" | "failed" | "lost";
|
|
950
955
|
costUsd: number | null;
|
|
951
|
-
tokens:
|
|
952
|
-
input: number;
|
|
953
|
-
output: number;
|
|
954
|
-
} | null;
|
|
956
|
+
tokens: TokenUsage | null;
|
|
955
957
|
computeSecs: number | null;
|
|
956
958
|
startedAt: string;
|
|
957
959
|
finishedAt: string;
|
|
@@ -967,6 +969,44 @@ export interface StepAttempt {
|
|
|
967
969
|
* attempt reported the figure — an in-process step rents no sandbox, and
|
|
968
970
|
* billing reads null as zero, not as "unknown, charge something". */
|
|
969
971
|
export declare function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" | "tokens" | "computeSecs">;
|
|
972
|
+
/**
|
|
973
|
+
* The token counts behind a step's cost. `input` is every input token the
|
|
974
|
+
* model saw; `cacheRead` and `cacheWrite` are the part of it the prompt
|
|
975
|
+
* cache served or stored — a read costs a tenth of fresh input, and a usage
|
|
976
|
+
* page wants to say how much of a step's context was re-read rather than
|
|
977
|
+
* re-sent. Steps recorded before 18 Oct 2026 have no cache fields: their
|
|
978
|
+
* `input` is the same total, but how much of it was cached is unknown.
|
|
979
|
+
*/
|
|
980
|
+
export interface TokenUsage {
|
|
981
|
+
input: number;
|
|
982
|
+
output: number;
|
|
983
|
+
cacheRead?: number;
|
|
984
|
+
cacheWrite?: number;
|
|
985
|
+
}
|
|
986
|
+
/** The record's shape from what a step reports (ExecOutcome.usage). */
|
|
987
|
+
export declare function tokensOf(usage: {
|
|
988
|
+
inputTokens: number;
|
|
989
|
+
outputTokens: number;
|
|
990
|
+
cacheReadTokens?: number;
|
|
991
|
+
cacheWriteTokens?: number;
|
|
992
|
+
} | null | undefined): TokenUsage | null;
|
|
993
|
+
/** Field-by-field sum; the cache fields are present in the result whenever
|
|
994
|
+
* any input had them, so a run of old and new steps still reads whole. */
|
|
995
|
+
export declare function sumTokens(list: readonly TokenUsage[]): TokenUsage;
|
|
996
|
+
/** The share of all input that the prompt cache served, 0–1; null when
|
|
997
|
+
* there was no input, or the record predates the split and cannot say. */
|
|
998
|
+
export declare function cacheHitRate(t: TokenUsage | null | undefined): number | null;
|
|
999
|
+
/** The cache share over several counts, taking only those that report the
|
|
1000
|
+
* split: a step recorded before it holds its cache traffic inside `input`,
|
|
1001
|
+
* and counting that as uncached would understate the rate. The rule the
|
|
1002
|
+
* Usage page and the CLI's totals follow too. */
|
|
1003
|
+
export declare function measuredHitRate(list: readonly (TokenUsage | null | undefined)[]): number | null;
|
|
1004
|
+
/** A run's token counts, summed over its steps; null when no step reported any. */
|
|
1005
|
+
export declare function runTokens(run: {
|
|
1006
|
+
steps: readonly {
|
|
1007
|
+
tokens?: TokenUsage | null;
|
|
1008
|
+
}[];
|
|
1009
|
+
}): TokenUsage | null;
|
|
970
1010
|
export interface StepRecord {
|
|
971
1011
|
agent: string;
|
|
972
1012
|
instruction: string;
|
|
@@ -1154,10 +1194,7 @@ export interface StepRecord {
|
|
|
1154
1194
|
/** Token counts behind costUsd, kept so usage reports can say "how many"
|
|
1155
1195
|
* and not only "how much" — the price of a token changes, the count is
|
|
1156
1196
|
* the fact. Absent on steps recorded before this existed. */
|
|
1157
|
-
tokens?:
|
|
1158
|
-
input: number;
|
|
1159
|
-
output: number;
|
|
1160
|
-
} | null;
|
|
1197
|
+
tokens?: TokenUsage | null;
|
|
1161
1198
|
/** What the sandbox actually touched, against what it reserved: CPU
|
|
1162
1199
|
* busy-seconds and peak memory from the pod's own cgroup, bytes on the
|
|
1163
1200
|
* wire from /proc/net/dev. Null per-field where the kernel interface
|
|
@@ -1319,10 +1356,7 @@ export interface RunSummaryStep {
|
|
|
1319
1356
|
group: number;
|
|
1320
1357
|
status: StepRecord["status"];
|
|
1321
1358
|
costUsd: number | null;
|
|
1322
|
-
tokens?:
|
|
1323
|
-
input: number;
|
|
1324
|
-
output: number;
|
|
1325
|
-
} | null;
|
|
1359
|
+
tokens?: TokenUsage | null;
|
|
1326
1360
|
computeSecs?: number | null;
|
|
1327
1361
|
skipReason?: string;
|
|
1328
1362
|
/** The last error event, for a failed step — what runFailure reads. */
|
package/dist/src/store.js
CHANGED
|
@@ -385,7 +385,6 @@ meta = {}) {
|
|
|
385
385
|
// about it. Before this, every deploy silently erased everything the agents
|
|
386
386
|
// had learned.
|
|
387
387
|
const shipped = new Set(files.map((f) => path.normalize(f.path)));
|
|
388
|
-
const isAgentOwned = (rel) => keptOnDeploy(rel, shipped);
|
|
389
388
|
// What is there now, for the revision: every editable file's content
|
|
390
389
|
// before this save replaces the tree.
|
|
391
390
|
const before = new Map();
|
|
@@ -399,6 +398,7 @@ meta = {}) {
|
|
|
399
398
|
}
|
|
400
399
|
}
|
|
401
400
|
}
|
|
401
|
+
const isAgentOwned = (rel) => keptOnDeploy(rel, shipped) || (meta.keepUntracked === true && !before.has(rel) && !shipped.has(rel));
|
|
402
402
|
const snapshot = fs.existsSync(dir) ? fs.mkdtempSync(path.join(dataRoot(), ".keep-")) : null;
|
|
403
403
|
const preserved = [];
|
|
404
404
|
if (snapshot) {
|
|
@@ -406,11 +406,15 @@ meta = {}) {
|
|
|
406
406
|
const rel = String(entry).split(path.sep).join("/");
|
|
407
407
|
const from = path.join(dir, rel);
|
|
408
408
|
try {
|
|
409
|
-
|
|
409
|
+
// lstat, never stat: a link is never followed. An agent folder's link
|
|
410
|
+
// to a file outside the workspace was copied in as that file's
|
|
411
|
+
// contents — on a restore with keepUntracked, any such link
|
|
412
|
+
// (Codex, 18 Oct 2026).
|
|
413
|
+
if (!fs.lstatSync(from).isFile() || !isAgentOwned(rel))
|
|
410
414
|
continue;
|
|
411
415
|
}
|
|
412
416
|
catch {
|
|
413
|
-
continue; // a
|
|
417
|
+
continue; // a file that vanished mid-deploy
|
|
414
418
|
}
|
|
415
419
|
const to = path.join(snapshot, rel);
|
|
416
420
|
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
@@ -2573,10 +2577,62 @@ export function sumAttempts(tries) {
|
|
|
2573
2577
|
const secs = tries.map((t) => t.computeSecs).filter((c) => typeof c === "number");
|
|
2574
2578
|
return {
|
|
2575
2579
|
costUsd: costs.length ? costs.reduce((a, b) => a + b, 0) : null,
|
|
2576
|
-
tokens: toks.length ? toks
|
|
2580
|
+
tokens: toks.length ? sumTokens(toks) : null,
|
|
2577
2581
|
computeSecs: secs.length ? secs.reduce((a, b) => a + b, 0) : null,
|
|
2578
2582
|
};
|
|
2579
2583
|
}
|
|
2584
|
+
/** The record's shape from what a step reports (ExecOutcome.usage). */
|
|
2585
|
+
export function tokensOf(usage) {
|
|
2586
|
+
if (!usage)
|
|
2587
|
+
return null;
|
|
2588
|
+
return {
|
|
2589
|
+
input: usage.inputTokens,
|
|
2590
|
+
output: usage.outputTokens,
|
|
2591
|
+
cacheRead: usage.cacheReadTokens ?? 0,
|
|
2592
|
+
cacheWrite: usage.cacheWriteTokens ?? 0,
|
|
2593
|
+
};
|
|
2594
|
+
}
|
|
2595
|
+
/** Field-by-field sum; the cache fields are present in the result whenever
|
|
2596
|
+
* any input had them, so a run of old and new steps still reads whole. */
|
|
2597
|
+
export function sumTokens(list) {
|
|
2598
|
+
const out = { input: 0, output: 0 };
|
|
2599
|
+
for (const t of list) {
|
|
2600
|
+
out.input += t.input;
|
|
2601
|
+
out.output += t.output;
|
|
2602
|
+
if (t.cacheRead !== undefined || t.cacheWrite !== undefined) {
|
|
2603
|
+
out.cacheRead = (out.cacheRead ?? 0) + (t.cacheRead ?? 0);
|
|
2604
|
+
out.cacheWrite = (out.cacheWrite ?? 0) + (t.cacheWrite ?? 0);
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
return out;
|
|
2608
|
+
}
|
|
2609
|
+
/** The share of all input that the prompt cache served, 0–1; null when
|
|
2610
|
+
* there was no input, or the record predates the split and cannot say. */
|
|
2611
|
+
export function cacheHitRate(t) {
|
|
2612
|
+
if (!t || t.cacheRead === undefined)
|
|
2613
|
+
return null;
|
|
2614
|
+
return t.input > 0 ? Math.min(1, t.cacheRead / t.input) : null;
|
|
2615
|
+
}
|
|
2616
|
+
/** The cache share over several counts, taking only those that report the
|
|
2617
|
+
* split: a step recorded before it holds its cache traffic inside `input`,
|
|
2618
|
+
* and counting that as uncached would understate the rate. The rule the
|
|
2619
|
+
* Usage page and the CLI's totals follow too. */
|
|
2620
|
+
export function measuredHitRate(list) {
|
|
2621
|
+
let read = 0;
|
|
2622
|
+
let all = 0;
|
|
2623
|
+
for (const t of list) {
|
|
2624
|
+
if (!t || t.cacheRead === undefined)
|
|
2625
|
+
continue;
|
|
2626
|
+
read += t.cacheRead;
|
|
2627
|
+
all += t.input;
|
|
2628
|
+
}
|
|
2629
|
+
return all > 0 ? read / all : null;
|
|
2630
|
+
}
|
|
2631
|
+
/** A run's token counts, summed over its steps; null when no step reported any. */
|
|
2632
|
+
export function runTokens(run) {
|
|
2633
|
+
const toks = run.steps.map((s) => s.tokens).filter((t) => !!t);
|
|
2634
|
+
return toks.length ? sumTokens(toks) : null;
|
|
2635
|
+
}
|
|
2580
2636
|
export function runFilePath(tenant, workspace, runId) {
|
|
2581
2637
|
assertSafeName(runId, "run id");
|
|
2582
2638
|
return path.join(workspaceDir(tenant, workspace), "runs", `${runId}.json`);
|
package/dist/src/tool-loop.d.ts
CHANGED
|
@@ -13,9 +13,12 @@ export interface LoopTool {
|
|
|
13
13
|
run: (args: Record<string, string | undefined>) => Promise<string> | string;
|
|
14
14
|
}
|
|
15
15
|
export interface LoopUsage {
|
|
16
|
-
/** Everything sent, cache reads and writes included
|
|
16
|
+
/** Everything sent, cache reads and writes included; the two cache
|
|
17
|
+
* fields say how much of it the prompt cache served or stored. */
|
|
17
18
|
inputTokens: number;
|
|
18
19
|
outputTokens: number;
|
|
20
|
+
cacheReadTokens: number;
|
|
21
|
+
cacheWriteTokens: number;
|
|
19
22
|
costUsd: number;
|
|
20
23
|
}
|
|
21
24
|
export interface LoopResult {
|
package/dist/src/tool-loop.js
CHANGED
|
@@ -170,6 +170,8 @@ export async function runToolLoop(o) {
|
|
|
170
170
|
usage = {
|
|
171
171
|
inputTokens: models.reduce((n, m) => n + (m.inputTokens ?? 0) + (m.cacheReadInputTokens ?? 0) + (m.cacheCreationInputTokens ?? 0), 0),
|
|
172
172
|
outputTokens: models.reduce((n, m) => n + (m.outputTokens ?? 0), 0),
|
|
173
|
+
cacheReadTokens: models.reduce((n, m) => n + (m.cacheReadInputTokens ?? 0), 0),
|
|
174
|
+
cacheWriteTokens: models.reduce((n, m) => n + (m.cacheCreationInputTokens ?? 0), 0),
|
|
173
175
|
costUsd,
|
|
174
176
|
};
|
|
175
177
|
if (message.subtype === "success") {
|
package/dist/src/translator.d.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
type Json = Record<string, unknown>;
|
|
2
|
+
/**
|
|
3
|
+
* An OpenAI-shaped usage object as Anthropic's: fresh input, cached input,
|
|
4
|
+
* output. Both OpenAI shapes count the cached part INSIDE the prompt total
|
|
5
|
+
* (Chat's prompt_tokens_details.cached_tokens, Responses'
|
|
6
|
+
* input_tokens_details.cached_tokens; DeepSeek says prompt_cache_hit_tokens),
|
|
7
|
+
* so it is taken out of input here or the step would count it twice.
|
|
8
|
+
*/
|
|
9
|
+
export declare function anthropicUsage(u: Json | undefined, wire: TranslatorWire): {
|
|
10
|
+
input: number;
|
|
11
|
+
cached: number;
|
|
12
|
+
output: number;
|
|
13
|
+
};
|
|
2
14
|
export type TranslatorWire = "chat" | "responses";
|
|
3
15
|
export interface TranslatorSpec {
|
|
4
16
|
/** The provider's base, e.g. https://api.openai.com/v1 — `/chat/completions`
|
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
|
@@ -34,6 +34,13 @@ export interface CatalogModel {
|
|
|
34
34
|
promptPrice: number | null;
|
|
35
35
|
/** USD per output token. */
|
|
36
36
|
completionPrice: number | null;
|
|
37
|
+
/** USD per input token read from / written to the prompt cache, when the
|
|
38
|
+
* gateway quotes them (OpenRouter's input_cache_read / input_cache_write).
|
|
39
|
+
* Models discount their cache differently — GPT-4o reads at half the
|
|
40
|
+
* input price, Claude at a tenth — so a quoted price beats any multiplier.
|
|
41
|
+
* Absent on catalogues cached before 2.1.1. */
|
|
42
|
+
cacheReadPrice?: number | null;
|
|
43
|
+
cacheWritePrice?: number | null;
|
|
37
44
|
/** Can this model make tool calls? Null when the gateway doesn't say. */
|
|
38
45
|
tools: boolean | null;
|
|
39
46
|
/** Does it take a reasoning-depth parameter? (What `effort:` needs.) */
|
|
@@ -63,7 +70,8 @@ const cacheFile = (baseUrl: string) =>
|
|
|
63
70
|
`${new URL(baseUrl).hostname.replace(/[^a-z0-9.-]/gi, "_")}.json`,
|
|
64
71
|
);
|
|
65
72
|
|
|
66
|
-
|
|
73
|
+
/** The gateway's /models payload as entries. Exported for its tests. */
|
|
74
|
+
export function parseModels(payload: unknown): CatalogModel[] {
|
|
67
75
|
const data = (payload as { data?: unknown[] })?.data;
|
|
68
76
|
if (!Array.isArray(data)) return [];
|
|
69
77
|
const out: CatalogModel[] = [];
|
|
@@ -75,8 +83,13 @@ function parseModels(payload: unknown): CatalogModel[] {
|
|
|
75
83
|
? m.supported_parameters.map(String)
|
|
76
84
|
: null;
|
|
77
85
|
const pricing = (m.pricing ?? {}) as Record<string, unknown>;
|
|
86
|
+
// Unquoted is unknown, not free: Number(null) and Number("") are 0, and
|
|
87
|
+
// a gateway answering `input_cache_read: null` would have made caching
|
|
88
|
+
// cost nothing. An explicit "0" stays 0.
|
|
78
89
|
const price = (k: string) => {
|
|
79
|
-
const
|
|
90
|
+
const v = pricing[k];
|
|
91
|
+
if (v == null || (typeof v === "string" && v.trim() === "")) return null;
|
|
92
|
+
const n = Number(v);
|
|
80
93
|
return Number.isFinite(n) ? n : null;
|
|
81
94
|
};
|
|
82
95
|
out.push({
|
|
@@ -85,6 +98,8 @@ function parseModels(payload: unknown): CatalogModel[] {
|
|
|
85
98
|
contextLength: Number.isFinite(Number(m.context_length)) ? Number(m.context_length) : null,
|
|
86
99
|
promptPrice: price("prompt"),
|
|
87
100
|
completionPrice: price("completion"),
|
|
101
|
+
cacheReadPrice: price("input_cache_read"),
|
|
102
|
+
cacheWritePrice: price("input_cache_write"),
|
|
88
103
|
tools: supported ? supported.includes("tools") : null,
|
|
89
104
|
reasoning: supported ? supported.includes("reasoning") : null,
|
|
90
105
|
efforts: Array.isArray((m.reasoning as Record<string, unknown> | undefined)?.supported_efforts)
|
|
@@ -245,13 +260,62 @@ export function clampEffort(
|
|
|
245
260
|
export function catalogCost(
|
|
246
261
|
catalog: Catalog | null,
|
|
247
262
|
modelId: string,
|
|
248
|
-
usage: { inputTokens: number; outputTokens: number },
|
|
263
|
+
usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number },
|
|
249
264
|
): number | null {
|
|
250
265
|
const m = findModel(catalog, modelId);
|
|
251
266
|
if (!m || m.promptPrice == null || m.completionPrice == null) return null;
|
|
252
|
-
return usage
|
|
267
|
+
return costOf(usage, tokenPriceOf(m)!);
|
|
253
268
|
}
|
|
254
269
|
|
|
270
|
+
/** What a token costs on one model: input, output, and — when the gateway
|
|
271
|
+
* quotes them — reads from and writes to the prompt cache. */
|
|
272
|
+
export interface TokenPrice {
|
|
273
|
+
input: number;
|
|
274
|
+
output: number;
|
|
275
|
+
cacheRead?: number | null;
|
|
276
|
+
cacheWrite?: number | null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** A catalogue entry's prices, or null when it has no input/output price. */
|
|
280
|
+
export function tokenPriceOf(m: CatalogModel | null | undefined): TokenPrice | null {
|
|
281
|
+
if (!m || m.promptPrice == null || m.completionPrice == null) return null;
|
|
282
|
+
return {
|
|
283
|
+
input: m.promptPrice,
|
|
284
|
+
output: m.completionPrice,
|
|
285
|
+
...(m.cacheReadPrice != null ? { cacheRead: m.cacheReadPrice } : {}),
|
|
286
|
+
...(m.cacheWritePrice != null ? { cacheWrite: m.cacheWritePrice } : {}),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The cost of a set of counts at a price. `inputTokens` is all input; the
|
|
292
|
+
* cache fields are parts of it. Cached input is priced at the model's own
|
|
293
|
+
* cache prices when the gateway quotes them, and only otherwise at
|
|
294
|
+
* Anthropic's multipliers over the input price. Before the counts were
|
|
295
|
+
* split (18 Oct 2026) every cached token was billed at the full rate.
|
|
296
|
+
*/
|
|
297
|
+
export function costOf(
|
|
298
|
+
usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number },
|
|
299
|
+
p: TokenPrice,
|
|
300
|
+
): number {
|
|
301
|
+
const read = usage.cacheReadTokens ?? 0;
|
|
302
|
+
const written = usage.cacheWriteTokens ?? 0;
|
|
303
|
+
const fresh = Math.max(0, usage.inputTokens - read - written);
|
|
304
|
+
return (
|
|
305
|
+
fresh * p.input +
|
|
306
|
+
written * (p.cacheWrite ?? p.input * CACHE_WRITE_MULTIPLIER) +
|
|
307
|
+
read * (p.cacheRead ?? p.input * CACHE_READ_MULTIPLIER) +
|
|
308
|
+
usage.outputTokens * p.output
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Anthropic's cache multipliers over the input price: a write costs a
|
|
313
|
+
* quarter more than fresh input, a read a tenth of it. The fallback when a
|
|
314
|
+
* gateway quotes no cache price — for the mid-step ceiling (priceTurn) and
|
|
315
|
+
* the record's repricing (catalogCost) alike. */
|
|
316
|
+
export const CACHE_WRITE_MULTIPLIER = 1.25;
|
|
317
|
+
export const CACHE_READ_MULTIPLIER = 0.1;
|
|
318
|
+
|
|
255
319
|
/** Catalogue entries as completion items: price per MTok, context, and the
|
|
256
320
|
* one capability that decides agent-worthiness. Tool-capable models first —
|
|
257
321
|
* 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,8 @@ 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
|
+
import type { TokenPrice } from "./catalog.ts";
|
|
31
32
|
import { isFileValue, fileContent } from "./secrets.ts";
|
|
32
33
|
import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKET, type RuntimeSpec } from "./runtime.ts";
|
|
33
34
|
import { egressCaSh, type NetworkLevel } from "./network.ts";
|
|
@@ -69,7 +70,7 @@ export interface ContainerStepInput {
|
|
|
69
70
|
* step-exec.ts. Values, so the driver can enforce them in the pod. */
|
|
70
71
|
budgetUsd?: number | null;
|
|
71
72
|
budgetNote?: string;
|
|
72
|
-
price?:
|
|
73
|
+
price?: TokenPrice | null;
|
|
73
74
|
verify?: string;
|
|
74
75
|
/** `output: json` — checked inside, where the reply is. */
|
|
75
76
|
output?: "json";
|
|
@@ -184,7 +185,7 @@ export interface ContainerStepOutcome {
|
|
|
184
185
|
/** The parsed JSON of an `output: json` step, crossing back as a value. */
|
|
185
186
|
data?: unknown;
|
|
186
187
|
costUsd: number | null;
|
|
187
|
-
usage?:
|
|
188
|
+
usage?: ExecUsage | null;
|
|
188
189
|
/** Every turn priced at its own model, when sub-agents ran on others —
|
|
189
190
|
* see ExecOutcome.turnsCostUsd. */
|
|
190
191
|
turnsCostUsd?: number;
|
|
@@ -1216,7 +1217,14 @@ export function parseDriverLine(
|
|
|
1216
1217
|
parsed.usage &&
|
|
1217
1218
|
typeof parsed.usage.inputTokens === "number" &&
|
|
1218
1219
|
typeof parsed.usage.outputTokens === "number"
|
|
1219
|
-
? {
|
|
1220
|
+
? {
|
|
1221
|
+
inputTokens: parsed.usage.inputTokens,
|
|
1222
|
+
outputTokens: parsed.usage.outputTokens,
|
|
1223
|
+
// A driver from before the split sends neither; zero, not
|
|
1224
|
+
// unknown, keeps the record's shape one thing.
|
|
1225
|
+
cacheReadTokens: typeof parsed.usage.cacheReadTokens === "number" ? parsed.usage.cacheReadTokens : 0,
|
|
1226
|
+
cacheWriteTokens: typeof parsed.usage.cacheWriteTokens === "number" ? parsed.usage.cacheWriteTokens : 0,
|
|
1227
|
+
}
|
|
1220
1228
|
: null,
|
|
1221
1229
|
res:
|
|
1222
1230
|
parsed.res && typeof parsed.res === "object"
|