@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.
Files changed (103) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/dist/advisory/executor.d.ts +9 -4
  3. package/dist/advisory/executor.d.ts.map +1 -1
  4. package/dist/advisory/executor.js +16 -9
  5. package/dist/advisory/executor.js.map +1 -1
  6. package/dist/constants/limits.d.ts +13 -0
  7. package/dist/constants/limits.d.ts.map +1 -1
  8. package/dist/constants/limits.js +14 -2
  9. package/dist/constants/limits.js.map +1 -1
  10. package/dist/manager/agent/lifecycle.d.ts +2 -2
  11. package/dist/manager/run/persistence.d.ts +37 -2
  12. package/dist/manager/run/persistence.d.ts.map +1 -1
  13. package/dist/manager/run/persistence.js +48 -7
  14. package/dist/manager/run/persistence.js.map +1 -1
  15. package/dist/pricing/catalogue.generated.d.ts +15 -0
  16. package/dist/pricing/catalogue.generated.d.ts.map +1 -0
  17. package/dist/pricing/catalogue.generated.js +293 -0
  18. package/dist/pricing/catalogue.generated.js.map +1 -0
  19. package/dist/pricing/index.d.ts +46 -0
  20. package/dist/pricing/index.d.ts.map +1 -0
  21. package/dist/pricing/index.js +70 -0
  22. package/dist/pricing/index.js.map +1 -0
  23. package/dist/public-runtime.d.ts +6 -1
  24. package/dist/public-runtime.d.ts.map +1 -1
  25. package/dist/public-runtime.js +21 -1
  26. package/dist/public-runtime.js.map +1 -1
  27. package/dist/public-types.d.ts +3 -1
  28. package/dist/public-types.d.ts.map +1 -1
  29. package/dist/run/LimitChecker.d.ts +10 -0
  30. package/dist/run/LimitChecker.d.ts.map +1 -1
  31. package/dist/run/LimitChecker.js +14 -2
  32. package/dist/run/LimitChecker.js.map +1 -1
  33. package/dist/run/command-gate.d.ts +107 -0
  34. package/dist/run/command-gate.d.ts.map +1 -0
  35. package/dist/run/command-gate.js +157 -0
  36. package/dist/run/command-gate.js.map +1 -0
  37. package/dist/run/index.d.ts +6 -0
  38. package/dist/run/index.d.ts.map +1 -1
  39. package/dist/run/index.js +3 -0
  40. package/dist/run/index.js.map +1 -1
  41. package/dist/run/memory-promoter.d.ts +70 -0
  42. package/dist/run/memory-promoter.d.ts.map +1 -0
  43. package/dist/run/memory-promoter.js +117 -0
  44. package/dist/run/memory-promoter.js.map +1 -0
  45. package/dist/run/workspace-fingerprint.d.ts +105 -0
  46. package/dist/run/workspace-fingerprint.d.ts.map +1 -0
  47. package/dist/run/workspace-fingerprint.js +147 -0
  48. package/dist/run/workspace-fingerprint.js.map +1 -0
  49. package/dist/runtime/query/checkpoint.d.ts +23 -2
  50. package/dist/runtime/query/checkpoint.d.ts.map +1 -1
  51. package/dist/runtime/query/checkpoint.js +24 -3
  52. package/dist/runtime/query/checkpoint.js.map +1 -1
  53. package/dist/runtime/query/guard.d.ts.map +1 -1
  54. package/dist/runtime/query/guard.js +1 -0
  55. package/dist/runtime/query/guard.js.map +1 -1
  56. package/dist/runtime/query/index.d.ts.map +1 -1
  57. package/dist/runtime/query/index.js +42 -0
  58. package/dist/runtime/query/index.js.map +1 -1
  59. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  60. package/dist/runtime/query/iteration/index.js +15 -2
  61. package/dist/runtime/query/iteration/index.js.map +1 -1
  62. package/dist/runtime/query/iteration/phases/advisory.d.ts.map +1 -1
  63. package/dist/runtime/query/iteration/phases/advisory.js +9 -1
  64. package/dist/runtime/query/iteration/phases/advisory.js.map +1 -1
  65. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  66. package/dist/runtime/query/iteration/phases/compaction.js +11 -2
  67. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  68. package/dist/store/run/conformance.d.ts.map +1 -1
  69. package/dist/store/run/conformance.js +8 -2
  70. package/dist/store/run/conformance.js.map +1 -1
  71. package/dist/types/common/index.d.ts +38 -2
  72. package/dist/types/common/index.d.ts.map +1 -1
  73. package/dist/types/run/stop-reason.d.ts +31 -1
  74. package/dist/types/run/stop-reason.d.ts.map +1 -1
  75. package/dist/utils/cost.d.ts +60 -2
  76. package/dist/utils/cost.d.ts.map +1 -1
  77. package/dist/utils/cost.js +126 -11
  78. package/dist/utils/cost.js.map +1 -1
  79. package/package.json +1 -1
  80. package/src/advisory/executor.ts +16 -9
  81. package/src/constants/limits.ts +14 -2
  82. package/src/manager/agent/lifecycle.ts +2 -2
  83. package/src/manager/run/persistence.ts +67 -7
  84. package/src/pricing/catalogue.generated.ts +308 -0
  85. package/src/pricing/index.ts +78 -0
  86. package/src/pricing/rates.source.json +206 -0
  87. package/src/public-runtime.ts +39 -0
  88. package/src/public-types.ts +3 -1
  89. package/src/run/LimitChecker.ts +24 -2
  90. package/src/run/command-gate.ts +234 -0
  91. package/src/run/index.ts +17 -0
  92. package/src/run/memory-promoter.ts +155 -0
  93. package/src/run/workspace-fingerprint.ts +193 -0
  94. package/src/runtime/query/checkpoint.ts +24 -3
  95. package/src/runtime/query/guard.ts +1 -0
  96. package/src/runtime/query/index.ts +42 -0
  97. package/src/runtime/query/iteration/index.ts +15 -2
  98. package/src/runtime/query/iteration/phases/advisory.ts +9 -1
  99. package/src/runtime/query/iteration/phases/compaction.ts +12 -2
  100. package/src/store/run/conformance.ts +8 -2
  101. package/src/types/common/index.ts +38 -2
  102. package/src/types/run/stop-reason.ts +30 -0
  103. 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 and default
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
- runMgr.recordTurnUsage(response.usage)
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
- ctx.runMgr.accumulateUsage(executionResult.usage)
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) => ctx.runMgr.accumulateUsage(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
- resolveTaskModel('compaction', ctx.taskRouter, ctx.runConfig.model),
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
- inputCostPer1M: number
78
- outputCostPer1M: number
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
- export function calculateCost(
12
- usage: TokenUsage,
13
- pricing: ModelPricing,
14
- cacheDiscount = 0,
15
- ): CostInfo {
16
- const inputCost = (usage.promptTokens / 1_000_000) * pricing.inputCostPer1M
17
- const outputCost = (usage.completionTokens / 1_000_000) * pricing.outputCostPer1M
18
- const gross = inputCost + outputCost
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: gross - cacheDiscount,
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 additional = calculateCost(additionalUsage, pricing, cacheDiscount)
167
+ const priced = priceUsage(additionalUsage, pricing)
168
+ const rates = priced.unpriced > 0 ? {} : ratesFor(current, pricing)
35
169
  return {
36
- inputCostPer1M: pricing.inputCostPer1M,
37
- outputCostPer1M: pricing.outputCostPer1M,
38
- totalCost: current.totalCost + additional.totalCost,
39
- cacheDiscount: current.cacheDiscount + additional.cacheDiscount,
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
+ }