@namzu/sdk 21.0.0 → 22.0.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/CHANGELOG.md +134 -0
- package/dist/advisory/executor.d.ts +9 -4
- package/dist/advisory/executor.d.ts.map +1 -1
- package/dist/advisory/executor.js +16 -9
- package/dist/advisory/executor.js.map +1 -1
- package/dist/constants/limits.d.ts +13 -0
- package/dist/constants/limits.d.ts.map +1 -1
- package/dist/constants/limits.js +14 -2
- package/dist/constants/limits.js.map +1 -1
- package/dist/manager/agent/lifecycle.d.ts +2 -2
- package/dist/manager/run/persistence.d.ts +37 -2
- package/dist/manager/run/persistence.d.ts.map +1 -1
- package/dist/manager/run/persistence.js +48 -7
- package/dist/manager/run/persistence.js.map +1 -1
- package/dist/pricing/catalogue.generated.d.ts +15 -0
- package/dist/pricing/catalogue.generated.d.ts.map +1 -0
- package/dist/pricing/catalogue.generated.js +293 -0
- package/dist/pricing/catalogue.generated.js.map +1 -0
- package/dist/pricing/index.d.ts +46 -0
- package/dist/pricing/index.d.ts.map +1 -0
- package/dist/pricing/index.js +70 -0
- package/dist/pricing/index.js.map +1 -0
- package/dist/public-runtime.d.ts +6 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +21 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-types.d.ts +3 -1
- package/dist/public-types.d.ts.map +1 -1
- package/dist/run/LimitChecker.d.ts +10 -0
- package/dist/run/LimitChecker.d.ts.map +1 -1
- package/dist/run/LimitChecker.js +14 -2
- package/dist/run/LimitChecker.js.map +1 -1
- package/dist/run/command-gate.d.ts +107 -0
- package/dist/run/command-gate.d.ts.map +1 -0
- package/dist/run/command-gate.js +157 -0
- package/dist/run/command-gate.js.map +1 -0
- package/dist/run/index.d.ts +6 -0
- package/dist/run/index.d.ts.map +1 -1
- package/dist/run/index.js +3 -0
- package/dist/run/index.js.map +1 -1
- package/dist/run/memory-promoter.d.ts +70 -0
- package/dist/run/memory-promoter.d.ts.map +1 -0
- package/dist/run/memory-promoter.js +117 -0
- package/dist/run/memory-promoter.js.map +1 -0
- package/dist/run/workspace-fingerprint.d.ts +105 -0
- package/dist/run/workspace-fingerprint.d.ts.map +1 -0
- package/dist/run/workspace-fingerprint.js +147 -0
- package/dist/run/workspace-fingerprint.js.map +1 -0
- package/dist/runtime/query/checkpoint.d.ts +23 -2
- package/dist/runtime/query/checkpoint.d.ts.map +1 -1
- package/dist/runtime/query/checkpoint.js +24 -3
- package/dist/runtime/query/checkpoint.js.map +1 -1
- package/dist/runtime/query/guard.d.ts.map +1 -1
- package/dist/runtime/query/guard.js +1 -0
- package/dist/runtime/query/guard.js.map +1 -1
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +42 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +15 -2
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/advisory.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/advisory.js +9 -1
- package/dist/runtime/query/iteration/phases/advisory.js.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.js +11 -2
- package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
- package/dist/store/run/conformance.d.ts.map +1 -1
- package/dist/store/run/conformance.js +8 -2
- package/dist/store/run/conformance.js.map +1 -1
- package/dist/types/common/index.d.ts +38 -2
- package/dist/types/common/index.d.ts.map +1 -1
- package/dist/types/run/stop-reason.d.ts +31 -1
- package/dist/types/run/stop-reason.d.ts.map +1 -1
- package/dist/utils/cost.d.ts +60 -2
- package/dist/utils/cost.d.ts.map +1 -1
- package/dist/utils/cost.js +126 -11
- package/dist/utils/cost.js.map +1 -1
- package/package.json +1 -1
- package/src/advisory/executor.ts +16 -9
- package/src/constants/limits.ts +14 -2
- package/src/manager/agent/lifecycle.ts +2 -2
- package/src/manager/run/persistence.ts +67 -7
- package/src/pricing/catalogue.generated.ts +308 -0
- package/src/pricing/index.ts +78 -0
- package/src/pricing/rates.source.json +206 -0
- package/src/public-runtime.ts +39 -0
- package/src/public-types.ts +3 -1
- package/src/run/LimitChecker.ts +24 -2
- package/src/run/command-gate.ts +234 -0
- package/src/run/index.ts +17 -0
- package/src/run/memory-promoter.ts +155 -0
- package/src/run/workspace-fingerprint.ts +193 -0
- package/src/runtime/query/checkpoint.ts +24 -3
- package/src/runtime/query/guard.ts +1 -0
- package/src/runtime/query/index.ts +42 -0
- package/src/runtime/query/iteration/index.ts +15 -2
- package/src/runtime/query/iteration/phases/advisory.ts +9 -1
- package/src/runtime/query/iteration/phases/compaction.ts +12 -2
- package/src/store/run/conformance.ts +8 -2
- package/src/types/common/index.ts +38 -2
- package/src/types/run/stop-reason.ts +30 -0
- package/src/utils/cost.ts +186 -16
|
@@ -43,11 +43,32 @@ export function toCheckpointListEntry(cp: IterationCheckpoint): CheckpointListEn
|
|
|
43
43
|
* the same restore path as any other checkpoint.
|
|
44
44
|
*
|
|
45
45
|
* The projection is lossy: `costInfo`, `guardState.elapsedMs` and
|
|
46
|
-
* `toolResultHashes` are not captured at emergency-save time
|
|
47
|
-
* to zero/empty values. The synthetic
|
|
46
|
+
* `toolResultHashes` are not captured at emergency-save time. The synthetic
|
|
48
47
|
* checkpoint id is derived deterministically from the emergency save id so
|
|
49
48
|
* re-projecting the same dump yields the same {@link CheckpointId}.
|
|
50
49
|
*
|
|
50
|
+
* ## The cost projection says "unknown", not "zero"
|
|
51
|
+
*
|
|
52
|
+
* `costInfo` used to default to `{ ...ZERO_COST }` beside a `tokenUsage` the
|
|
53
|
+
* dump preserves faithfully — a run that had spent real money coming back as
|
|
54
|
+
* one that had spent nothing. That is the same lie the price catalogue was
|
|
55
|
+
* added to end, arriving through the restore path instead of the accumulation
|
|
56
|
+
* one, and it took `runConfig.costLimitUsd` back to being enforced against a
|
|
57
|
+
* zero with it.
|
|
58
|
+
*
|
|
59
|
+
* It also broke the accumulator downstream. `accumulateCost` decides whether
|
|
60
|
+
* one rate card describes the whole total by asking whether the total is
|
|
61
|
+
* FRESH — zero cost, zero unpriced tokens, no rates — and `ZERO_COST` is
|
|
62
|
+
* exactly that shape. So the first turn after such a resume adopted its own
|
|
63
|
+
* rate card as covering the pre-crash spend too, and reported a number that
|
|
64
|
+
* was confidently wrong while looking like a measured one.
|
|
65
|
+
*
|
|
66
|
+
* Saying instead that the pre-crash tokens are UNPRICED is the true statement,
|
|
67
|
+
* in the vocabulary {@link CostInfo} already has: nobody knows what they cost,
|
|
68
|
+
* because nothing recorded it. It needs no marker of its own to stay correct,
|
|
69
|
+
* and it is not fresh-shaped, so the accumulator does the right thing without
|
|
70
|
+
* being told about this path at all.
|
|
71
|
+
*
|
|
51
72
|
* See ses_005-deterministic-replay design §2 + §5.2.
|
|
52
73
|
*/
|
|
53
74
|
export function projectEmergencyToCheckpoint(dump: EmergencySaveData): IterationCheckpoint {
|
|
@@ -64,7 +85,7 @@ export function projectEmergencyToCheckpoint(dump: EmergencySaveData): Iteration
|
|
|
64
85
|
iteration: dump.currentIteration,
|
|
65
86
|
messages: dump.messages,
|
|
66
87
|
tokenUsage: dump.tokenUsage,
|
|
67
|
-
costInfo: { ...ZERO_COST },
|
|
88
|
+
costInfo: { ...ZERO_COST, unpricedTokens: dump.tokenUsage.totalTokens },
|
|
68
89
|
guardState: {
|
|
69
90
|
iterationCount: dump.currentIteration,
|
|
70
91
|
elapsedMs: Math.max(0, dump.savedAt - dump.startedAt),
|
|
@@ -92,6 +92,7 @@ export class GuardCoordinator {
|
|
|
92
92
|
aborted: abortSignal.aborted,
|
|
93
93
|
totalTokens: runMgr.tokenUsage.totalTokens,
|
|
94
94
|
totalCost: runMgr.costInfo.totalCost,
|
|
95
|
+
unpricedTokens: runMgr.costInfo.unpricedTokens,
|
|
95
96
|
currentIteration: runMgr.currentIteration,
|
|
96
97
|
startTime: this.startTime,
|
|
97
98
|
}
|
|
@@ -16,6 +16,7 @@ import type { CompactionConfig } from '../../config/runtime.js'
|
|
|
16
16
|
import { TOOL_OUTPUT_DIR_NAME } from '../../constants/tools/index.js'
|
|
17
17
|
import { EmergencySaveManager } from '../../manager/run/emergency.js'
|
|
18
18
|
import type { RunPersistence } from '../../manager/run/persistence.js'
|
|
19
|
+
import { resolveModelPricing } from '../../pricing/index.js'
|
|
19
20
|
import { resolveProviderCapabilities } from '../../provider/capabilities.js'
|
|
20
21
|
import {
|
|
21
22
|
type ProviderChainMember,
|
|
@@ -607,6 +608,46 @@ function assertCostIsAttributable(
|
|
|
607
608
|
})
|
|
608
609
|
}
|
|
609
610
|
|
|
611
|
+
/**
|
|
612
|
+
* Refuse a budget that cannot be measured.
|
|
613
|
+
*
|
|
614
|
+
* `runConfig.costLimitUsd` is enforced against `costInfo.totalCost`, and that
|
|
615
|
+
* total only moves for tokens something has a rate for. A model no rate card
|
|
616
|
+
* covers therefore produced a limit that could never trip — a host that set a
|
|
617
|
+
* cost cap had no cost cap, and nothing said so. That was every run before the
|
|
618
|
+
* price catalogue existed, which is how it went unnoticed.
|
|
619
|
+
*
|
|
620
|
+
* Refusing at the front is the cheap half of the answer: it costs the caller
|
|
621
|
+
* nothing, fires before any spend, and names both ways out. The other half is
|
|
622
|
+
* the `cost_unmeasurable` stop, for the models this cannot see — a step naming
|
|
623
|
+
* its own, or a chain member declaring one.
|
|
624
|
+
*
|
|
625
|
+
* This is the same shape `advisory/budget.ts` already applies to
|
|
626
|
+
* `AdvisoryBudget.maxCostPerRun`, one layer down, and for the same reason. The
|
|
627
|
+
* run path simply never had it.
|
|
628
|
+
*/
|
|
629
|
+
function assertBudgetIsMeasurable(params: QueryParams): void {
|
|
630
|
+
const limit = params.runConfig.costLimitUsd
|
|
631
|
+
if (limit === undefined || limit <= 0) return
|
|
632
|
+
// A host-supplied table prices whatever it is pointed at, so a caller who
|
|
633
|
+
// brought one has answered the question themselves.
|
|
634
|
+
if (params.pricing !== undefined) return
|
|
635
|
+
const model = params.runConfig.model
|
|
636
|
+
if (resolveModelPricing(params.provider.id, model) !== undefined) return
|
|
637
|
+
|
|
638
|
+
throw new NamzuError({
|
|
639
|
+
code: 'invalid_config',
|
|
640
|
+
message:
|
|
641
|
+
`runConfig.costLimitUsd is set to ${limit}, but no rate is known for model "${model}" on ` +
|
|
642
|
+
`provider "${params.provider.id}". The limit is enforced against the run's accumulated ` +
|
|
643
|
+
'cost, and tokens with no rate never reach that total — so the budget would read as ' +
|
|
644
|
+
'satisfied for the whole run and stop nothing. Either pass `pricing` to declare the rate ' +
|
|
645
|
+
'yourself, add the model to packages/sdk/src/pricing/rates.source.json, or drop ' +
|
|
646
|
+
'`costLimitUsd` and bound the run with `tokenBudget`, which is measurable here.',
|
|
647
|
+
details: { model, providerId: params.provider.id, costLimitUsd: limit },
|
|
648
|
+
})
|
|
649
|
+
}
|
|
650
|
+
|
|
610
651
|
export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run> {
|
|
611
652
|
// Boot-time filesystem migration (session-hierarchy.md §13.4.1). First
|
|
612
653
|
// call per process per root actually runs; subsequent calls short-circuit
|
|
@@ -639,6 +680,7 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
|
|
|
639
680
|
...(params.fallbackProviders ?? []),
|
|
640
681
|
]
|
|
641
682
|
assertCostIsAttributable(chain, params.pricing)
|
|
683
|
+
assertBudgetIsMeasurable(params)
|
|
642
684
|
const withRetry = (provider: LLMProvider): LLMProvider =>
|
|
643
685
|
params.retry === false
|
|
644
686
|
? provider
|
|
@@ -461,7 +461,17 @@ export class IterationOrchestrator {
|
|
|
461
461
|
})()
|
|
462
462
|
|
|
463
463
|
// Main-loop turn: also records the prompt size compaction reads.
|
|
464
|
-
|
|
464
|
+
//
|
|
465
|
+
// `servedBy` is what prices it, and it is the exact pair —
|
|
466
|
+
// the member at the cursor when the stream ended, and the
|
|
467
|
+
// model it was asked for. This is the seam that ended the
|
|
468
|
+
// always-zero cost: the rate lookup happens per turn,
|
|
469
|
+
// against who actually answered, rather than against one
|
|
470
|
+
// table the run was constructed with.
|
|
471
|
+
runMgr.recordTurnUsage(response.usage, {
|
|
472
|
+
providerId: servedBy.providerId,
|
|
473
|
+
model: servedBy.model,
|
|
474
|
+
})
|
|
465
475
|
|
|
466
476
|
// The turn went through, so the run is not sitting on an
|
|
467
477
|
// irreducible prompt any more. Re-arm relief for the next one.
|
|
@@ -1529,7 +1539,10 @@ export class IterationOrchestrator {
|
|
|
1529
1539
|
}),
|
|
1530
1540
|
)
|
|
1531
1541
|
|
|
1532
|
-
this.ctx.runMgr.accumulateUsage(response.usage
|
|
1542
|
+
this.ctx.runMgr.accumulateUsage(response.usage, {
|
|
1543
|
+
providerId: this.ctx.runMgr.servingProviderId,
|
|
1544
|
+
model,
|
|
1545
|
+
})
|
|
1533
1546
|
|
|
1534
1547
|
const assistantMsg = createAssistantMessage(response.message.content)
|
|
1535
1548
|
this.ctx.runMgr.pushMessage(assistantMsg)
|
|
@@ -117,7 +117,15 @@ export async function runAdvisoryPhase(
|
|
|
117
117
|
// `tokenBudget: 200_000` and an `on_error` trigger could send well
|
|
118
118
|
// past 200k and never trip `token_budget`. The usage is already in
|
|
119
119
|
// hand — this just tells the accountant about it.
|
|
120
|
-
|
|
120
|
+
// Priced against the ADVISOR's own driver and model, not the run's. An
|
|
121
|
+
// advisor carries its own `provider`, so attributing its tokens to
|
|
122
|
+
// whoever is serving the main loop would price one vendor's work at
|
|
123
|
+
// another's card — which is the class of quiet wrongness the whole
|
|
124
|
+
// catalogue exists to remove, and it would be invisible here.
|
|
125
|
+
ctx.runMgr.accumulateUsage(executionResult.usage, {
|
|
126
|
+
providerId: advisor.provider.id,
|
|
127
|
+
model: advisor.model,
|
|
128
|
+
})
|
|
121
129
|
|
|
122
130
|
advisoryCtx.recordCall({
|
|
123
131
|
advisorId: advisor.id,
|
|
@@ -542,19 +542,29 @@ export async function runCompactionCheck(
|
|
|
542
542
|
let compactedContent: string
|
|
543
543
|
|
|
544
544
|
if (config.llmVerification && manager.slotCount() < config.richStateThreshold) {
|
|
545
|
+
// Named once and used twice on purpose. The model the summariser is
|
|
546
|
+
// asked for is also the model its tokens are priced at, and computing
|
|
547
|
+
// the two separately is how they drift — a router that sends compaction
|
|
548
|
+
// to a cheap model while the bill is written against the expensive one
|
|
549
|
+
// is a mistake with no symptom.
|
|
550
|
+
const compactionModel = resolveTaskModel('compaction', ctx.taskRouter, ctx.runConfig.model)
|
|
545
551
|
compactedContent = await buildVerifiedSummary(
|
|
546
552
|
manager,
|
|
547
553
|
olderMessages,
|
|
548
554
|
ctx.provider,
|
|
549
555
|
config,
|
|
550
|
-
(usage) =>
|
|
556
|
+
(usage) =>
|
|
557
|
+
ctx.runMgr.accumulateUsage(usage, {
|
|
558
|
+
providerId: ctx.runMgr.servingProviderId,
|
|
559
|
+
model: compactionModel,
|
|
560
|
+
}),
|
|
551
561
|
// The one model call a run makes that the user never asked for. It
|
|
552
562
|
// reads a transcript and writes a summary, which is the cheapest
|
|
553
563
|
// thing a small model does well, and it fires on exactly the long
|
|
554
564
|
// runs where the primary model is most expensive. `taskRouter` had
|
|
555
565
|
// been accepted, validated and threaded through four types since it
|
|
556
566
|
// was added, and nothing ever consulted it.
|
|
557
|
-
|
|
567
|
+
compactionModel,
|
|
558
568
|
)
|
|
559
569
|
} else {
|
|
560
570
|
compactedContent = serializeState(manager.getState())
|
|
@@ -177,11 +177,17 @@ const FOREIGN_TENANT = 'tnt_conformance_other' as TenantId
|
|
|
177
177
|
/** Fixed instant, so every expiry in a case is judged against one clock. */
|
|
178
178
|
const NOW = 5_000_000
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* A run that cost nothing and has nothing unaccounted for — the shape a store
|
|
182
|
+
* has to round-trip, not a claim about any real run. Carries no rate fields,
|
|
183
|
+
* matching `ZERO_COST`: a store fixture that invents `inputCostPer1M: 0` would
|
|
184
|
+
* be asserting a rate card of zero, which now means "this model is free"
|
|
185
|
+
* rather than "no rate was applied".
|
|
186
|
+
*/
|
|
180
187
|
const NO_COST: CostInfo = {
|
|
181
|
-
inputCostPer1M: 0,
|
|
182
|
-
outputCostPer1M: 0,
|
|
183
188
|
totalCost: 0,
|
|
184
189
|
cacheDiscount: 0,
|
|
190
|
+
unpricedTokens: 0,
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
let checkpointSeq = 0
|
|
@@ -74,10 +74,46 @@ export function mergeTokenUsage(current: TokenUsage, next: TokenUsage): TokenUsa
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
export interface CostInfo {
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
/**
|
|
78
|
+
* The input rate this total was accumulated at — present only when ONE rate
|
|
79
|
+
* card describes the whole total.
|
|
80
|
+
*
|
|
81
|
+
* Absent means "no single rate describes this", not "zero". Three ways to
|
|
82
|
+
* get there: nothing has been accumulated yet, the run spanned two
|
|
83
|
+
* differently-priced models, or part of it was accumulated at no known rate
|
|
84
|
+
* at all. This is the same contract {@link TokenUsage.reasoningTokens} uses
|
|
85
|
+
* one field up, for the same reason — a number invented to fill the slot
|
|
86
|
+
* would be indistinguishable from a measured one.
|
|
87
|
+
*
|
|
88
|
+
* The field used to be required, and a run that swapped models reported
|
|
89
|
+
* whichever card was applied last, which is a claim about the whole total
|
|
90
|
+
* that was true of only part of it.
|
|
91
|
+
*/
|
|
92
|
+
inputCostPer1M?: number
|
|
93
|
+
/** As {@link CostInfo.inputCostPer1M}, for output tokens. */
|
|
94
|
+
outputCostPer1M?: number
|
|
79
95
|
totalCost: number
|
|
80
96
|
cacheDiscount: number
|
|
97
|
+
/**
|
|
98
|
+
* Tokens accumulated at no known rate, so `totalCost` does not include what
|
|
99
|
+
* they cost.
|
|
100
|
+
*
|
|
101
|
+
* This exists so a consumer can tell "this run cost nothing" from "nobody
|
|
102
|
+
* knows what this run cost". Reporting the second as zero is the defect the
|
|
103
|
+
* price catalogue was added to fix, one level down: a total that is always
|
|
104
|
+
* zero and a total that is zero because it is unknown look identical, and
|
|
105
|
+
* `runConfig.costLimitUsd` is enforced against both.
|
|
106
|
+
*
|
|
107
|
+
* - `totalCost: 0, unpricedTokens: 0` — the run genuinely cost nothing
|
|
108
|
+
* (local inference bills per token exactly never).
|
|
109
|
+
* - `totalCost: 0, unpricedTokens: 4210` — nobody knows.
|
|
110
|
+
* - `totalCost: 0.12, unpricedTokens: 900` — partly known; the total is a
|
|
111
|
+
* floor, not the answer.
|
|
112
|
+
*
|
|
113
|
+
* A count rather than a flag because a run mixes turns: a step can name its
|
|
114
|
+
* own model and a provider chain can swap members mid-run.
|
|
115
|
+
*/
|
|
116
|
+
unpricedTokens: number
|
|
81
117
|
}
|
|
82
118
|
|
|
83
119
|
export interface PlatformError {
|
|
@@ -2,6 +2,36 @@ export type StopReason =
|
|
|
2
2
|
| 'end_turn'
|
|
3
3
|
| 'token_budget'
|
|
4
4
|
| 'cost_limit'
|
|
5
|
+
/**
|
|
6
|
+
* A `costLimitUsd` was set and part of the run ran at a rate nobody has, so
|
|
7
|
+
* the limit could not be measured.
|
|
8
|
+
*
|
|
9
|
+
* Distinct from `cost_limit`, and the distinction is the reason this value
|
|
10
|
+
* exists: nothing was overspent. Reporting `cost_limit` would send the
|
|
11
|
+
* reader to look at spend that was never computed, and would hide the one
|
|
12
|
+
* fact they need — that the budget they configured was unenforceable for
|
|
13
|
+
* part of this run.
|
|
14
|
+
*
|
|
15
|
+
* `query()` refuses the same combination up front, so this is the case
|
|
16
|
+
* preflight cannot see: a step naming its own model, or a chain member
|
|
17
|
+
* declaring one, arriving at a model the price catalogue has no row for.
|
|
18
|
+
* `costInfo.unpricedTokens` says how much of the run it covers.
|
|
19
|
+
*
|
|
20
|
+
* ## The closing call is outside the budget, deliberately
|
|
21
|
+
*
|
|
22
|
+
* Every hard stop is followed by one more model call — `requestFinalResponse`
|
|
23
|
+
* asks for a closing summary, and the guard does not run again before or
|
|
24
|
+
* after it. That is pre-existing and true of `cost_limit` and
|
|
25
|
+
* `token_budget` alike, and it is stated here rather than left to be
|
|
26
|
+
* discovered because for THIS reason it cannot be otherwise: the model that
|
|
27
|
+
* triggered the stop is by definition one with no rate, so the closing
|
|
28
|
+
* call's cost is unmeasurable by construction. Bounding it would mean
|
|
29
|
+
* refusing to close the run at all, which loses the work.
|
|
30
|
+
*
|
|
31
|
+
* Its tokens are counted, so `costInfo.unpricedTokens` includes them and
|
|
32
|
+
* the run reports honestly what it could not price.
|
|
33
|
+
*/
|
|
34
|
+
| 'cost_unmeasurable'
|
|
5
35
|
| 'timeout'
|
|
6
36
|
| 'max_iterations'
|
|
7
37
|
| 'cancelled'
|
package/src/utils/cost.ts
CHANGED
|
@@ -1,42 +1,195 @@
|
|
|
1
1
|
import { ZERO_COST } from '../constants/limits.js'
|
|
2
2
|
import type { CostInfo, TokenUsage } from '../types/common/index.js'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* What a driver's cache tokens cost, and what they mean.
|
|
6
|
+
*
|
|
7
|
+
* `promptIncludesCacheReads` is a property of the DRIVER, not of the model, and
|
|
8
|
+
* it is why this is a nested object rather than two more rate fields. The
|
|
9
|
+
* drivers in this repository disagree about it: two report `promptTokens`
|
|
10
|
+
* excluding cache reads and charge them on top, one reports `promptTokens`
|
|
11
|
+
* already containing them. Applying a read rate without knowing which one you
|
|
12
|
+
* have is wrong by the entire cache volume, in a direction that changes with
|
|
13
|
+
* whoever served the turn — so the fact travels with the rates and is never
|
|
14
|
+
* inferred.
|
|
15
|
+
*/
|
|
16
|
+
export interface CacheRates {
|
|
17
|
+
/**
|
|
18
|
+
* `true`: `usage.cachedTokens` is a SUBSET of `usage.promptTokens`, so the
|
|
19
|
+
* billable input is the difference.
|
|
20
|
+
* `false`: they are additional to it.
|
|
21
|
+
*/
|
|
22
|
+
readonly promptIncludesCacheReads: boolean
|
|
23
|
+
readonly readCostPer1M: number
|
|
24
|
+
/**
|
|
25
|
+
* Absent when the driver never reports a cache write. Tokens that arrive
|
|
26
|
+
* anyway are counted as unpriced rather than charged at some neighbouring
|
|
27
|
+
* rate, so a driver that starts reporting them surfaces as a gap instead of
|
|
28
|
+
* a quietly wrong total.
|
|
29
|
+
*/
|
|
30
|
+
readonly writeCostPer1M?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
4
33
|
export interface ModelPricing {
|
|
5
34
|
inputCostPer1M: number
|
|
6
35
|
outputCostPer1M: number
|
|
36
|
+
/**
|
|
37
|
+
* Absent means the caller declared a two-rate card and gets a two-rate
|
|
38
|
+
* answer: prompt and completion tokens are priced, and cache tokens are
|
|
39
|
+
* left inside whichever of those the driver already counted them in. That
|
|
40
|
+
* is the host-supplied case and it is a declaration, not a guess on our
|
|
41
|
+
* part. The catalogue always supplies this.
|
|
42
|
+
*/
|
|
43
|
+
cache?: CacheRates
|
|
7
44
|
}
|
|
8
45
|
|
|
9
46
|
export { ZERO_COST }
|
|
10
47
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
cacheDiscount
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
48
|
+
interface Priced {
|
|
49
|
+
readonly cost: number
|
|
50
|
+
/** What the cache reads saved against paying the full input rate. */
|
|
51
|
+
readonly cacheDiscount: number
|
|
52
|
+
/** Tokens this rate card had no rate for. */
|
|
53
|
+
readonly unpriced: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function priceUsage(usage: TokenUsage, pricing: ModelPricing): Priced {
|
|
57
|
+
const perMillion = (tokens: number, rate: number) => (tokens / 1_000_000) * rate
|
|
58
|
+
|
|
59
|
+
const cache = pricing.cache
|
|
60
|
+
if (cache === undefined) {
|
|
61
|
+
return {
|
|
62
|
+
cost:
|
|
63
|
+
perMillion(usage.promptTokens, pricing.inputCostPer1M) +
|
|
64
|
+
perMillion(usage.completionTokens, pricing.outputCostPer1M),
|
|
65
|
+
cacheDiscount: 0,
|
|
66
|
+
unpriced: 0,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Never negative: a driver that reports more cached tokens than prompt
|
|
71
|
+
// tokens is contradicting itself, and clamping keeps that from turning into
|
|
72
|
+
// a credit on the bill.
|
|
73
|
+
const billableInput = cache.promptIncludesCacheReads
|
|
74
|
+
? Math.max(0, usage.promptTokens - usage.cachedTokens)
|
|
75
|
+
: usage.promptTokens
|
|
19
76
|
|
|
77
|
+
const writesRated = cache.writeCostPer1M !== undefined
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
cost:
|
|
81
|
+
perMillion(billableInput, pricing.inputCostPer1M) +
|
|
82
|
+
perMillion(usage.completionTokens, pricing.outputCostPer1M) +
|
|
83
|
+
perMillion(usage.cachedTokens, cache.readCostPer1M) +
|
|
84
|
+
(writesRated ? perMillion(usage.cacheWriteTokens, cache.writeCostPer1M as number) : 0),
|
|
85
|
+
// Reported, not subtracted. The saving is already inside `cost` — the
|
|
86
|
+
// reads were charged at the read rate rather than the input rate — so
|
|
87
|
+
// taking it off again would double-count it. This field was previously
|
|
88
|
+
// declared, defaulted to zero at both call sites, and passed by nobody;
|
|
89
|
+
// it now carries the one quantity it was always named for.
|
|
90
|
+
cacheDiscount: perMillion(usage.cachedTokens, pricing.inputCostPer1M - cache.readCostPer1M),
|
|
91
|
+
unpriced: writesRated ? 0 : usage.cacheWriteTokens,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function calculateCost(usage: TokenUsage, pricing: ModelPricing): CostInfo {
|
|
96
|
+
const priced = priceUsage(usage, pricing)
|
|
20
97
|
return {
|
|
21
98
|
inputCostPer1M: pricing.inputCostPer1M,
|
|
22
99
|
outputCostPer1M: pricing.outputCostPer1M,
|
|
23
|
-
totalCost:
|
|
24
|
-
cacheDiscount,
|
|
100
|
+
totalCost: priced.cost,
|
|
101
|
+
cacheDiscount: priced.cacheDiscount,
|
|
102
|
+
unpricedTokens: priced.unpriced,
|
|
25
103
|
}
|
|
26
104
|
}
|
|
27
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Whether `current` is a total nothing has been added to yet.
|
|
108
|
+
*
|
|
109
|
+
* All three conditions, not one. A run whose only turn so far was unpriced has
|
|
110
|
+
* a zero total and no rate fields too, and adopting the next turn's rate card
|
|
111
|
+
* as though it described the whole total would be exactly the wrong claim.
|
|
112
|
+
*
|
|
113
|
+
* This predicate is only sound while every writer of a `CostInfo` goes through
|
|
114
|
+
* this module or states the truth in its vocabulary. One did not:
|
|
115
|
+
* `projectEmergencyToCheckpoint` wrote `ZERO_COST` beside a real, non-zero
|
|
116
|
+
* `tokenUsage`, which is byte-identical to a fresh total — so a run resumed
|
|
117
|
+
* from an emergency dump would have adopted its next turn's rate card as
|
|
118
|
+
* covering spend that happened before the crash. That projection now records
|
|
119
|
+
* the pre-crash tokens as unpriced, which is both true and, usefully, not
|
|
120
|
+
* fresh-shaped.
|
|
121
|
+
*/
|
|
122
|
+
function isFresh(current: CostInfo): boolean {
|
|
123
|
+
return (
|
|
124
|
+
current.totalCost === 0 &&
|
|
125
|
+
current.unpricedTokens === 0 &&
|
|
126
|
+
current.inputCostPer1M === undefined &&
|
|
127
|
+
current.outputCostPer1M === undefined
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The rate fields that honestly describe `current + pricing`.
|
|
133
|
+
*
|
|
134
|
+
* Kept when one card still covers the whole total; dropped when it does not.
|
|
135
|
+
* Dropping rather than overwriting is the change: the previous version wrote
|
|
136
|
+
* the incoming card over whatever was there, so a run that swapped models
|
|
137
|
+
* reported the last card applied as though it had priced every token.
|
|
138
|
+
*
|
|
139
|
+
* Equal-but-distinct cards are treated as one, deliberately. Two models at the
|
|
140
|
+
* same published rate produce a total that a single rate card DOES describe,
|
|
141
|
+
* which is the only claim these two fields make — they name a rate, not a
|
|
142
|
+
* model, and `Run.steps[].servedBy` carries which model served each turn.
|
|
143
|
+
*/
|
|
144
|
+
function ratesFor(
|
|
145
|
+
current: CostInfo,
|
|
146
|
+
pricing: ModelPricing,
|
|
147
|
+
): Pick<CostInfo, 'inputCostPer1M' | 'outputCostPer1M'> {
|
|
148
|
+
const incoming = {
|
|
149
|
+
inputCostPer1M: pricing.inputCostPer1M,
|
|
150
|
+
outputCostPer1M: pricing.outputCostPer1M,
|
|
151
|
+
}
|
|
152
|
+
if (isFresh(current)) return incoming
|
|
153
|
+
if (
|
|
154
|
+
current.inputCostPer1M === pricing.inputCostPer1M &&
|
|
155
|
+
current.outputCostPer1M === pricing.outputCostPer1M
|
|
156
|
+
) {
|
|
157
|
+
return incoming
|
|
158
|
+
}
|
|
159
|
+
return {}
|
|
160
|
+
}
|
|
161
|
+
|
|
28
162
|
export function accumulateCost(
|
|
29
163
|
current: CostInfo,
|
|
30
164
|
additionalUsage: TokenUsage,
|
|
31
165
|
pricing: ModelPricing,
|
|
32
|
-
cacheDiscount = 0,
|
|
33
166
|
): CostInfo {
|
|
34
|
-
const
|
|
167
|
+
const priced = priceUsage(additionalUsage, pricing)
|
|
168
|
+
const rates = priced.unpriced > 0 ? {} : ratesFor(current, pricing)
|
|
35
169
|
return {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
170
|
+
...rates,
|
|
171
|
+
totalCost: current.totalCost + priced.cost,
|
|
172
|
+
cacheDiscount: current.cacheDiscount + priced.cacheDiscount,
|
|
173
|
+
unpricedTokens: current.unpricedTokens + priced.unpriced,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Record tokens that were consumed at a rate nobody has.
|
|
179
|
+
*
|
|
180
|
+
* The alternative was to add nothing and leave the total alone, which is how
|
|
181
|
+
* every run came to report `$0.00` for work that cost real money. Counting the
|
|
182
|
+
* tokens instead makes the gap a fact the caller can read and the budget guard
|
|
183
|
+
* can refuse on, rather than an absence that looks like an answer.
|
|
184
|
+
*
|
|
185
|
+
* The rate fields go, if they were there: a total that omits part of a run is
|
|
186
|
+
* not described by any single card.
|
|
187
|
+
*/
|
|
188
|
+
export function accumulateUnpricedCost(current: CostInfo, additionalUsage: TokenUsage): CostInfo {
|
|
189
|
+
return {
|
|
190
|
+
totalCost: current.totalCost,
|
|
191
|
+
cacheDiscount: current.cacheDiscount,
|
|
192
|
+
unpricedTokens: current.unpricedTokens + additionalUsage.totalTokens,
|
|
40
193
|
}
|
|
41
194
|
}
|
|
42
195
|
|
|
@@ -45,3 +198,20 @@ export function formatCost(usd: number): string {
|
|
|
45
198
|
if (usd < 0.01) return `$${usd.toFixed(4)}`
|
|
46
199
|
return `$${usd.toFixed(2)}`
|
|
47
200
|
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* How a total should be shown, given what is and is not known about it.
|
|
204
|
+
*
|
|
205
|
+
* Exists so that no surface has to re-derive the free/unknown distinction from
|
|
206
|
+
* two fields and get it subtly wrong. `@namzu/cli` printed
|
|
207
|
+
* `'$0.0000 (this provider reported no price)'` for every run, because every
|
|
208
|
+
* run was unpriced; now the two cases really are different and the string has
|
|
209
|
+
* to follow.
|
|
210
|
+
*/
|
|
211
|
+
export function describeCost(cost: CostInfo): string {
|
|
212
|
+
if (cost.unpricedTokens === 0) return formatCost(cost.totalCost)
|
|
213
|
+
if (cost.totalCost === 0) {
|
|
214
|
+
return `unknown (${cost.unpricedTokens} tokens at no known rate)`
|
|
215
|
+
}
|
|
216
|
+
return `at least ${formatCost(cost.totalCost)} (${cost.unpricedTokens} tokens at no known rate)`
|
|
217
|
+
}
|