@agent-finops/core 0.9.5 → 0.9.6

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.
@@ -22,7 +22,15 @@ export function latestObservedWorkingDirectory(calls) {
22
22
  .sort((left, right) => right.timestamp.localeCompare(left.timestamp))[0]
23
23
  ?.workingDirectory;
24
24
  }
25
- export const localAgentFinancialParserVersion = 1;
25
+ // Bumped to 2 when session-cumulative Codex calls gained `maxRequestPromptTokens`
26
+ // (largest single request in the session). Financial caches written by v1 lack
27
+ // this per-request tier evidence, so a >272K-cumulative Codex session would
28
+ // stay voided to "missing" on reuse; a version mismatch re-parses instead.
29
+ // Bumped to 3 when user forks (`forked_from_id`) started resetting their
30
+ // inherited baseline: v2 entries priced a fork's replayed parent history as
31
+ // the child's own usage, so those cached amounts are overstated and must not
32
+ // be reused.
33
+ export const localAgentFinancialParserVersion = 3;
26
34
  /**
27
35
  * Qualitative parser contract version. Bumped to 2 with the checkpointed
28
36
  * streaming path (A4b): entries and checkpoints written by the pre-streaming
@@ -33,9 +41,15 @@ export const localAgentFinancialParserVersion = 1;
33
41
  * cross-file completion evidence (`subagentCompletions`, from both Task tool
34
42
  * results and background task-notifications): entries persisted by the
35
43
  * collapsing parser must re-parse rather than silently keep merging subagent
36
- * runs into their parent session.
44
+ * runs into their parent session. Bumped to 5 when Codex reducer checkpoints
45
+ * gained `maxTurnPromptTokens` (largest single request): a checkpoint written
46
+ * by v4 lacks the running per-request maximum, so restoring it could under-read
47
+ * the tier evidence for a cumulative session that crossed the boundary — a
48
+ * version mismatch discards it and re-parses from scratch instead. Bumped to 6
49
+ * with the user-fork inherited-baseline fix: v5 entries and checkpoints treated
50
+ * a fork's replayed parent history as the child's own usage and activity.
37
51
  */
38
- export const localAgentQualitativeParserVersion = 4;
52
+ export const localAgentQualitativeParserVersion = 6;
39
53
  /**
40
54
  * Conservative launch defaults for action-capable qualitative evidence.
41
55
  * Callers must still inspect `qualitativeCoverage` before deriving a finding:
@@ -384,6 +398,42 @@ function parseCodexTurnUsage(value) {
384
398
  }
385
399
  };
386
400
  }
401
+ /**
402
+ * The tier-relevant prompt size of one Codex `last_token_usage` turn: its full
403
+ * request input (Codex `input_tokens` already includes cached input), which is
404
+ * exactly `effectivePromptTokens` of the parsed turn usage. Returned only when
405
+ * the field parses to a non-negative number, so an unreadable turn never lowers
406
+ * a running maximum. Used to prove whether any single request in a cumulative
407
+ * session crossed a per-request tier threshold.
408
+ */
409
+ function codexTurnPromptTokens(turn) {
410
+ const input = tokenComponentOf(turn.input_tokens);
411
+ return input === undefined ? undefined : input;
412
+ }
413
+ /**
414
+ * Whether a Codex rollout replays another session's history before its own
415
+ * work, so the cumulative counter at its first real task is an inherited
416
+ * baseline rather than this session's usage.
417
+ *
418
+ * TWO kinds of rollout do this, and only the first was recognized before:
419
+ * - subagent rollouts (`thread_source: "subagent"`, or a `source.subagent`);
420
+ * - USER FORKS (`forked_from_id`), which carry `thread_source: "user"` and
421
+ * replay the parent transcript verbatim. Observed: a forked rollout whose
422
+ * first 9,051 of 9,442 token_count events are byte-identical copies of the
423
+ * parent's, all with earlier parent timestamps, carrying 1,204,265,192 of
424
+ * its 1,256,637,395 final cumulative input tokens (95.9%) — parent usage
425
+ * that was being billed again under the child, and again down each fork
426
+ * chain.
427
+ *
428
+ * This is deliberately NOT folded into `isSubagent`: that flag also drives
429
+ * activity attribution and checkpoint identity, where a user fork is a normal
430
+ * user session and must not be relabelled a subagent.
431
+ */
432
+ function codexHasInheritedHistory(payload) {
433
+ return stringOf(payload.thread_source) === "subagent" ||
434
+ isRecord(payload.source) && "subagent" in payload.source ||
435
+ stringOf(payload.forked_from_id) !== undefined;
436
+ }
387
437
  /** Parse one Claude Code transcript (JSONL). Exported for tests. */
388
438
  export function parseClaudeCodeTranscript(content, filePath = "", sinceMs, onDiagnostic) {
389
439
  const calls = [];
@@ -634,6 +684,7 @@ function createCodexRolloutParserState() {
634
684
  fileCounts: new Map(),
635
685
  toolCallCount: 0,
636
686
  isSubagent: false,
687
+ hasInheritedHistory: false,
637
688
  malformedLines: 0
638
689
  };
639
690
  }
@@ -666,6 +717,7 @@ function consumeCodexRolloutLine(state, line, onEntry) {
666
717
  state.rootStartedAtMs = timestampMilliseconds(payload.timestamp ?? entry.timestamp);
667
718
  state.isSubagent = stringOf(payload.thread_source) === "subagent" ||
668
719
  isRecord(payload.source) && "subagent" in payload.source;
720
+ state.hasInheritedHistory = codexHasInheritedHistory(payload);
669
721
  state.parentSessionId = stringOf(payload.parent_thread_id);
670
722
  }
671
723
  if (entry.type === "turn_context" && payload) {
@@ -677,7 +729,7 @@ function consumeCodexRolloutLine(state, line, onEntry) {
677
729
  state.rootCwd = stringOf(payload.cwd);
678
730
  }
679
731
  }
680
- if (state.isSubagent &&
732
+ if (state.hasInheritedHistory &&
681
733
  !state.rootTaskStarted &&
682
734
  payload?.type === "task_started" &&
683
735
  isRootSpecificTaskStart(payload.started_at, state.rootStartedAtMs)) {
@@ -688,6 +740,11 @@ function consumeCodexRolloutLine(state, line, onEntry) {
688
740
  // prompts/files cannot become the child's focus. Restored checkpoint
689
741
  // evidence predating the boundary is parent history too and resets with
690
742
  // it (the raw session cwd deliberately survives, exactly as rootCwd).
743
+ //
744
+ // Replayed task_started events keep the PARENT's original started_at, so
745
+ // `isRootSpecificTaskStart` rejects them and the first accepted task is
746
+ // this session's own — which is why the boundary lands exactly at the end
747
+ // of the replay.
691
748
  state.inheritedUsageBaseline = state.lastTotal;
692
749
  state.lastTotal = undefined;
693
750
  state.rootTaskStarted = true;
@@ -699,10 +756,11 @@ function consumeCodexRolloutLine(state, line, onEntry) {
699
756
  state.toolCallCount = 0;
700
757
  state.model = undefined;
701
758
  state.lastTurn = undefined;
759
+ state.maxTurnPromptTokens = undefined;
702
760
  state.lastRateLimits = undefined;
703
761
  state.lastActivityAt = toIso(stringOf(entry.timestamp)) ?? state.startedAt;
704
762
  }
705
- if (payload?.type === "task_started" && (!state.isSubagent || state.rootTaskStarted)) {
763
+ if (payload?.type === "task_started" && (!state.hasInheritedHistory || state.rootTaskStarted)) {
706
764
  state.pendingTaskTurnId = stringOf(payload.turn_id);
707
765
  state.completedTask = undefined;
708
766
  }
@@ -757,6 +815,10 @@ function consumeCodexRolloutLine(state, line, onEntry) {
757
815
  if (turn) {
758
816
  state.lastTurn = turn;
759
817
  state.lastActivityAt = eventTimestamp;
818
+ const turnPrompt = codexTurnPromptTokens(turn);
819
+ if (turnPrompt !== undefined) {
820
+ state.maxTurnPromptTokens = Math.max(state.maxTurnPromptTokens ?? 0, turnPrompt);
821
+ }
760
822
  }
761
823
  const rateLimits = parseCodexRateLimits(payload.rate_limits, eventTimestamp);
762
824
  if (rateLimits) {
@@ -774,7 +836,7 @@ function finishCodexRolloutParse(state, onDiagnostic) {
774
836
  if (state.malformedLines > 0) {
775
837
  onDiagnostic?.({ code: "malformed_jsonl", count: state.malformedLines });
776
838
  }
777
- if (!state.lastTotal || state.isSubagent && !state.rootTaskStarted)
839
+ if (!state.lastTotal || state.hasInheritedHistory && !state.rootTaskStarted)
778
840
  return [];
779
841
  const parsedUsage = parseCodexCumulativeUsage(state.lastTotal, state.inheritedUsageBaseline);
780
842
  const parsedTurn = state.lastTurn
@@ -834,6 +896,9 @@ function finishCodexRolloutParse(state, onDiagnostic) {
834
896
  : {}),
835
897
  usageScope: "session_cumulative",
836
898
  usageSupport,
899
+ ...(usageSupport === "complete" && state.maxTurnPromptTokens !== undefined
900
+ ? { maxRequestPromptTokens: state.maxTurnPromptTokens }
901
+ : {}),
837
902
  ...(parsedUsage.reportedTotalTokens !== undefined
838
903
  ? { reportedTotalTokens: parsedUsage.reportedTotalTokens }
839
904
  : {}),
@@ -1999,6 +2064,7 @@ function serializeCodexReducerState(state) {
1999
2064
  ...(state.rootStartedAtMs !== undefined ? { rootStartedAtMs: state.rootStartedAtMs } : {}),
2000
2065
  rootTaskStarted: state.rootTaskStarted,
2001
2066
  isSubagent: state.isSubagent,
2067
+ hasInheritedHistory: state.hasInheritedHistory,
2002
2068
  ...(state.parentSessionId !== undefined ? { parentSessionId: state.parentSessionId } : {}),
2003
2069
  ...(state.pendingTaskTurnId !== undefined ? { pendingTaskTurnId: state.pendingTaskTurnId } : {}),
2004
2070
  ...(state.completedTask !== undefined ? { completedTask: state.completedTask } : {}),
@@ -2006,6 +2072,9 @@ function serializeCodexReducerState(state) {
2006
2072
  ...(state.lastActivityAt !== undefined ? { lastActivityAt: state.lastActivityAt } : {}),
2007
2073
  ...(state.lastTotal !== undefined ? { lastTotal: state.lastTotal } : {}),
2008
2074
  ...(state.lastTurn !== undefined ? { lastTurn: state.lastTurn } : {}),
2075
+ ...(state.maxTurnPromptTokens !== undefined
2076
+ ? { maxTurnPromptTokens: state.maxTurnPromptTokens }
2077
+ : {}),
2009
2078
  ...(state.inheritedUsageBaseline !== undefined
2010
2079
  ? { inheritedUsageBaseline: state.inheritedUsageBaseline }
2011
2080
  : {}),
@@ -2050,6 +2119,8 @@ function isPersistedCodexReducerState(value) {
2050
2119
  return typeof value.rootSessionMetaSeen === "boolean" &&
2051
2120
  typeof value.rootTaskStarted === "boolean" &&
2052
2121
  typeof value.isSubagent === "boolean" &&
2122
+ (value.hasInheritedHistory === undefined ||
2123
+ typeof value.hasInheritedHistory === "boolean") &&
2053
2124
  optionalString(value.model) &&
2054
2125
  optionalString(value.sessionId) &&
2055
2126
  optionalString(value.sourceVersion) &&
@@ -2062,6 +2133,7 @@ function isPersistedCodexReducerState(value) {
2062
2133
  optionalString(value.lastActivityAt) &&
2063
2134
  optionalRecord(value.lastTotal) &&
2064
2135
  optionalRecord(value.lastTurn) &&
2136
+ optionalFinite(value.maxTurnPromptTokens) &&
2065
2137
  optionalRecord(value.inheritedUsageBaseline) &&
2066
2138
  validRateLimits &&
2067
2139
  validRootCwd &&
@@ -2084,6 +2156,10 @@ function restoreCodexReducerState(persisted) {
2084
2156
  state.rootStartedAtMs = persisted.rootStartedAtMs;
2085
2157
  state.rootTaskStarted = persisted.rootTaskStarted;
2086
2158
  state.isSubagent = persisted.isSubagent;
2159
+ // Pre-fork-fix checkpoints carry no flag; fall back to the subagent bit so a
2160
+ // restored subagent keeps its boundary (a restored user fork re-parses, its
2161
+ // parser version having changed).
2162
+ state.hasInheritedHistory = persisted.hasInheritedHistory ?? persisted.isSubagent;
2087
2163
  state.parentSessionId = persisted.parentSessionId;
2088
2164
  state.pendingTaskTurnId = persisted.pendingTaskTurnId;
2089
2165
  state.completedTask = persisted.completedTask;
@@ -2091,6 +2167,7 @@ function restoreCodexReducerState(persisted) {
2091
2167
  state.lastActivityAt = persisted.lastActivityAt;
2092
2168
  state.lastTotal = persisted.lastTotal;
2093
2169
  state.lastTurn = persisted.lastTurn;
2170
+ state.maxTurnPromptTokens = persisted.maxTurnPromptTokens;
2094
2171
  state.inheritedUsageBaseline = persisted.inheritedUsageBaseline;
2095
2172
  state.lastRateLimits = persisted.lastRateLimits;
2096
2173
  state.restoredRootCwd = persisted.rootCwd;
@@ -2726,8 +2803,11 @@ async function readCodexFinancialFile(file) {
2726
2803
  const rootPayload = rootEntry?.type === "session_meta" && isRecord(rootEntry.payload)
2727
2804
  ? rootEntry.payload
2728
2805
  : undefined;
2729
- const isSubagent = Boolean(rootPayload) && (stringOf(rootPayload?.thread_source) === "subagent" ||
2730
- isRecord(rootPayload?.source) && "subagent" in rootPayload.source);
2806
+ // A rollout that replays another session's history must be read WHOLE:
2807
+ // its inherited-baseline boundary sits mid-file, and a proof-complete tail
2808
+ // would never reach it. User forks need this exactly as subagents do.
2809
+ const hasInheritedHistory = rootPayload !== undefined &&
2810
+ codexHasInheritedHistory(rootPayload);
2731
2811
  const entriesReverse = [];
2732
2812
  let malformedLines = 0;
2733
2813
  let prefilteredLines = 0;
@@ -2738,7 +2818,7 @@ async function readCodexFinancialFile(file) {
2738
2818
  let suffixPartsReverse = [];
2739
2819
  let stoppedEarly = false;
2740
2820
  let bytesSkipped = 0;
2741
- const proof = createCodexReverseProof(isSubagent, !rootPayload);
2821
+ const proof = createCodexReverseProof(hasInheritedHistory, !rootPayload);
2742
2822
  while (position > 0 && !stoppedEarly) {
2743
2823
  const chunkStart = Math.max(0, position - FINANCIAL_REVERSE_CHUNK_BYTES);
2744
2824
  const length = position - chunkStart;
@@ -2979,9 +3059,9 @@ function skipJsonWhitespace(input, start) {
2979
3059
  index += 1;
2980
3060
  return index;
2981
3061
  }
2982
- function createCodexReverseProof(isSubagent, forceFullScan) {
3062
+ function createCodexReverseProof(hasInheritedHistory, forceFullScan) {
2983
3063
  return {
2984
- isSubagent,
3064
+ hasInheritedHistory,
2985
3065
  forceFullScan,
2986
3066
  totalSeen: false,
2987
3067
  turnSeen: false,
@@ -2990,7 +3070,7 @@ function createCodexReverseProof(isSubagent, forceFullScan) {
2990
3070
  };
2991
3071
  }
2992
3072
  function observeCodexReverseProof(proof, entry) {
2993
- if (proof.forceFullScan || proof.isSubagent)
3073
+ if (proof.forceFullScan || proof.hasInheritedHistory)
2994
3074
  return false;
2995
3075
  const payload = isRecord(entry.payload) ? entry.payload : undefined;
2996
3076
  const info = payload?.type === "token_count" && isRecord(payload.info)
@@ -3007,7 +3087,7 @@ function observeCodexReverseProof(proof, entry) {
3007
3087
  if (entry.type === "turn_context" && stringOf(payload?.model)) {
3008
3088
  proof.modelSeen = true;
3009
3089
  }
3010
- return !proof.isSubagent &&
3090
+ return !proof.hasInheritedHistory &&
3011
3091
  proof.totalSeen &&
3012
3092
  proof.turnSeen &&
3013
3093
  proof.rateLimitsSeen &&
@@ -3112,7 +3192,8 @@ function createCodexFinancialStreamState() {
3112
3192
  return {
3113
3193
  rootSessionMetaSeen: false,
3114
3194
  rootTaskStarted: false,
3115
- isSubagent: false
3195
+ isSubagent: false,
3196
+ hasInheritedHistory: false
3116
3197
  };
3117
3198
  }
3118
3199
  function consumeCodexFinancialEntry(state, entry) {
@@ -3126,12 +3207,13 @@ function consumeCodexFinancialEntry(state, entry) {
3126
3207
  state.rootStartedAtMs = timestampMilliseconds(payload.timestamp ?? entry.timestamp);
3127
3208
  state.isSubagent = stringOf(payload.thread_source) === "subagent" ||
3128
3209
  isRecord(payload.source) && "subagent" in payload.source;
3210
+ state.hasInheritedHistory = codexHasInheritedHistory(payload);
3129
3211
  }
3130
3212
  if (entry.type === "turn_context" && payload) {
3131
3213
  state.model = stringOf(payload.model) ?? state.model;
3132
3214
  state.rootCwd ??= stringOf(payload.cwd);
3133
3215
  }
3134
- if (state.isSubagent &&
3216
+ if (state.hasInheritedHistory &&
3135
3217
  !state.rootTaskStarted &&
3136
3218
  payload?.type === "task_started" &&
3137
3219
  isRootSpecificTaskStart(payload.started_at, state.rootStartedAtMs)) {
@@ -3140,6 +3222,7 @@ function consumeCodexFinancialEntry(state, entry) {
3140
3222
  state.rootTaskStarted = true;
3141
3223
  state.model = undefined;
3142
3224
  state.lastTurn = undefined;
3225
+ state.maxTurnPromptTokens = undefined;
3143
3226
  state.lastRateLimits = undefined;
3144
3227
  state.lastActivityAt = toIso(stringOf(entry.timestamp)) ?? state.startedAt;
3145
3228
  }
@@ -3162,13 +3245,17 @@ function consumeCodexFinancialEntry(state, entry) {
3162
3245
  if (turn) {
3163
3246
  state.lastTurn = turn;
3164
3247
  state.lastActivityAt = eventTimestamp;
3248
+ const turnPrompt = codexTurnPromptTokens(turn);
3249
+ if (turnPrompt !== undefined) {
3250
+ state.maxTurnPromptTokens = Math.max(state.maxTurnPromptTokens ?? 0, turnPrompt);
3251
+ }
3165
3252
  }
3166
3253
  const rateLimits = parseCodexRateLimits(payload.rate_limits, eventTimestamp);
3167
3254
  if (rateLimits)
3168
3255
  state.lastRateLimits = rateLimits;
3169
3256
  }
3170
3257
  function finishCodexFinancialStream(state, onDiagnostic) {
3171
- if (!state.lastTotal || state.isSubagent && !state.rootTaskStarted)
3258
+ if (!state.lastTotal || state.hasInheritedHistory && !state.rootTaskStarted)
3172
3259
  return undefined;
3173
3260
  const parsedUsage = parseCodexCumulativeUsage(state.lastTotal, state.inheritedUsageBaseline);
3174
3261
  const parsedTurn = state.lastTurn
@@ -3196,6 +3283,9 @@ function finishCodexFinancialStream(state, onDiagnostic) {
3196
3283
  : {}),
3197
3284
  usageScope: "session_cumulative",
3198
3285
  usageSupport,
3286
+ ...(usageSupport === "complete" && state.maxTurnPromptTokens !== undefined
3287
+ ? { maxRequestPromptTokens: state.maxTurnPromptTokens }
3288
+ : {}),
3199
3289
  ...(parsedUsage.reportedTotalTokens !== undefined
3200
3290
  ? { reportedTotalTokens: parsedUsage.reportedTotalTokens }
3201
3291
  : {}),
@@ -3234,10 +3324,14 @@ export function aggregateCallsForFormats(calls, descriptors) {
3234
3324
  const usageSupported = groupCalls.every((call) => call.usageSupport !== "unsupported_token_shape");
3235
3325
  const sourceVersions = [...new Set(groupCalls.flatMap((call) => call.sourceVersion ? [call.sourceVersion] : []))].sort().slice(0, 8);
3236
3326
  const tieredPricingEvidenceSupported = !usesPromptTieredPricing(model) ||
3237
- groupCalls.every((call) => canPriceTokenUsageAtScope(model, call.usage, call.usageScope === "turn" ? "request" : "aggregate") && (agent !== "gemini-cli" || hasCompleteGeminiPromptEvidence(call)));
3327
+ groupCalls.every((call) => canPriceTokenUsageAtScope(model, call.usage, call.usageScope === "turn" ? "request" : "aggregate", call.maxRequestPromptTokens) && (agent !== "gemini-cli" || hasCompleteGeminiPromptEvidence(call)));
3238
3328
  const amountUsd = usageSupported && tieredPricingEvidenceSupported && format
3239
3329
  ? usesPromptTieredPricing(model)
3240
- ? estimateTokenCostsUsd(model, groupCalls.map((call) => call.usage))
3330
+ ? estimateTokenCostsUsd(model, groupCalls.map((call) => call.usage),
3331
+ // Fix each cumulative slice's tier from its largest single request
3332
+ // rather than its cache-inflated sum. Turn-scoped slices carry no
3333
+ // such evidence and keep selecting their tier from their own prompt.
3334
+ groupCalls.map((call) => call.maxRequestPromptTokens))
3241
3335
  : estimateTokenCostUsd(model, usage)
3242
3336
  : undefined;
3243
3337
  const priced = usageSupported && typeof amountUsd === "number";
@@ -50,8 +50,17 @@ export declare function estimateTokenCostUsd(model: string, usage: TokenUsage):
50
50
  * models whose entire request moves to a higher rate above a prompt-size
51
51
  * threshold; pricing a daily token sum would incorrectly treat many small
52
52
  * requests as one large request.
53
+ *
54
+ * `tierPromptTokens[i]`, when provided, fixes the tier of `usages[i]` from
55
+ * request-level evidence instead of the slice's own prompt total. A
56
+ * session-cumulative slice is a sum of many requests whose prompt total
57
+ * routinely clears a per-request threshold on cache reads alone, even though no
58
+ * single request did; supplying the largest single request's prompt keeps such
59
+ * a slice on the base tier (and pricing it there is exact, since the base rate
60
+ * distributes over the sum). Omitting the array preserves single-request
61
+ * behaviour: each slice's own prompt selects its tier.
53
62
  */
54
- export declare function estimateTokenCostsUsd(model: string, usages: readonly TokenUsage[]): number | undefined;
63
+ export declare function estimateTokenCostsUsd(model: string, usages: readonly TokenUsage[], tierPromptTokens?: readonly (number | undefined)[]): number | undefined;
55
64
  /** Whether this model's rate selection depends on each request's prompt size. */
56
65
  export declare function usesPromptTieredPricing(model: string): boolean;
57
66
  /** Prompt-size threshold for tiered request pricing, when one is published. */
@@ -60,9 +69,13 @@ export declare function promptTierThreshold(model: string): number | undefined;
60
69
  * Tiered prices are selected per request, never from a multi-request sum.
61
70
  * An aggregate is still unambiguous when its entire non-negative prompt-side
62
71
  * total is at or below the threshold; then no constituent request can have
63
- * crossed it. Larger aggregates fail closed until request-level evidence is
64
- * available.
72
+ * crossed it. It is also unambiguous when request-level evidence
73
+ * (`maxRequestPromptTokens`, the largest single request the aggregate contains)
74
+ * proves that no constituent request crossed the threshold: every request was
75
+ * base-tier, so the whole sum is base-tier and prices exactly at the base rate.
76
+ * Larger aggregates without such evidence fail closed to keep an unpriceable
77
+ * total honestly "missing" rather than guessing a tier.
65
78
  */
66
- export declare function canPriceTokenUsageAtScope(model: string, usage: TokenUsage, scope: "request" | "aggregate"): boolean;
79
+ export declare function canPriceTokenUsageAtScope(model: string, usage: TokenUsage, scope: "request" | "aggregate", maxRequestPromptTokens?: number): boolean;
67
80
  export {};
68
81
  //# sourceMappingURL=modelPricing.d.ts.map
@@ -20,21 +20,38 @@ const pricingRules = [
20
20
  { match: /^claude-haiku-4/i, inputPerM: 1, outputPerM: 5 },
21
21
  { match: /^claude-3-7-sonnet|^claude-3-5-sonnet/i, inputPerM: 3, outputPerM: 15 },
22
22
  { match: /^claude-3-5-haiku/i, inputPerM: 0.8, outputPerM: 4 },
23
- // OpenAI (newer and more specific families must precede the GPT-5 fallback)
23
+ // OpenAI (newer and more specific families must precede the GPT-5 fallback).
24
+ // Rates from developers.openai.com/api/docs/pricing cross-checked against each
25
+ // model's own doc page, both fetched 2026-08-25.
26
+ //
27
+ // GPT-5.6 ships exactly three API models — sol, terra, luna
28
+ // (developers.openai.com/api/docs/models, 2026-08-25). Each 5.6/5.5/5.4 rule
29
+ // below is END-ANCHORED on purpose: an undocumented or future sibling
30
+ // (gpt-5.6-cyber, gpt-5.5-pro, gpt-5.7-sol) must fall through to
31
+ // honest-unpriced rather than inherit a neighbour's rate. That is the 0.9.4
32
+ // `^kimi-k2` mistake one family up, and it is the expensive direction here —
33
+ // the pre-0.9.6 `^gpt-5.6(?:-sol)?$` rule carried GPT-5.5's numbers, so every
34
+ // gpt-5.6-sol record was overstated by 25% on input and 50% on output.
35
+ //
36
+ // Long context, published identically on all three 5.6 pages plus 5.5/5.4:
37
+ // "Prompts with >272K input tokens are priced at 2x input and 1.5x output for
38
+ // the full request." Cached input scales with the 2x input leg.
24
39
  {
25
- match: /^gpt-5\.6(?:-sol)?$/i,
26
- inputPerM: 5,
27
- outputPerM: 30,
28
- cacheReadPerM: 0.5,
40
+ // developers.openai.com/api/docs/models/gpt-5.6-sol, 2026-08-25
41
+ match: /^gpt-5\.6-sol$/i,
42
+ inputPerM: 4,
43
+ outputPerM: 20,
44
+ cacheReadPerM: 0.4,
29
45
  abovePromptTokens: {
30
46
  threshold: 272_000,
31
- inputPerM: 10,
32
- outputPerM: 45,
33
- cacheReadPerM: 1
47
+ inputPerM: 8,
48
+ outputPerM: 30,
49
+ cacheReadPerM: 0.8
34
50
  }
35
51
  },
36
52
  {
37
- match: /^gpt-5\.6-terra/i,
53
+ // developers.openai.com/api/docs/models/gpt-5.6-terra, 2026-08-25
54
+ match: /^gpt-5\.6-terra$/i,
38
55
  inputPerM: 2,
39
56
  outputPerM: 12,
40
57
  cacheReadPerM: 0.2,
@@ -46,7 +63,8 @@ const pricingRules = [
46
63
  }
47
64
  },
48
65
  {
49
- match: /^gpt-5\.6-luna/i,
66
+ // developers.openai.com/api/docs/models/gpt-5.6-luna, 2026-08-25
67
+ match: /^gpt-5\.6-luna$/i,
50
68
  inputPerM: 0.2,
51
69
  outputPerM: 1.2,
52
70
  cacheReadPerM: 0.02,
@@ -57,15 +75,50 @@ const pricingRules = [
57
75
  cacheReadPerM: 0.04
58
76
  }
59
77
  },
60
- { match: /^gpt-5\.5(?:-codex)?/i, inputPerM: 5, outputPerM: 30, cacheReadPerM: 0.5 },
78
+ {
79
+ // developers.openai.com/api/docs/models/gpt-5.5, 2026-08-25
80
+ match: /^gpt-5\.5$/i,
81
+ inputPerM: 5,
82
+ outputPerM: 30,
83
+ cacheReadPerM: 0.5,
84
+ abovePromptTokens: {
85
+ threshold: 272_000,
86
+ inputPerM: 10,
87
+ outputPerM: 45,
88
+ cacheReadPerM: 1
89
+ }
90
+ },
91
+ // gpt-5.5-codex bills at the 5.5 base rate but is absent from the published
92
+ // long-context list, so it deliberately carries no >272K tier.
93
+ { match: /^gpt-5\.5-codex$/i, inputPerM: 5, outputPerM: 30, cacheReadPerM: 0.5 },
61
94
  { match: /^gpt-5\.4-mini/i, inputPerM: 0.75, outputPerM: 4.5, cacheReadPerM: 0.075 },
62
95
  { match: /^gpt-5\.4-nano/i, inputPerM: 0.2, outputPerM: 1.25, cacheReadPerM: 0.02 },
63
- { match: /^gpt-5\.4/i, inputPerM: 2.5, outputPerM: 15, cacheReadPerM: 0.25 },
96
+ {
97
+ // developers.openai.com/api/docs/models/gpt-5.4, 2026-08-25
98
+ match: /^gpt-5\.4$/i,
99
+ inputPerM: 2.5,
100
+ outputPerM: 15,
101
+ cacheReadPerM: 0.25,
102
+ abovePromptTokens: {
103
+ threshold: 272_000,
104
+ inputPerM: 5,
105
+ outputPerM: 22.5,
106
+ cacheReadPerM: 0.5
107
+ }
108
+ },
64
109
  { match: /^gpt-5\.3-codex/i, inputPerM: 1.75, outputPerM: 14, cacheReadPerM: 0.175 },
65
110
  { match: /^gpt-5\.2(?:-codex)?/i, inputPerM: 1.75, outputPerM: 14, cacheReadPerM: 0.175 },
66
111
  { match: /^gpt-5(?:\.1)?-codex/i, inputPerM: 1.25, outputPerM: 10, cacheReadPerM: 0.125 },
67
112
  { match: /^gpt-5(?:\.1)?-mini/i, inputPerM: 0.25, outputPerM: 2, cacheReadPerM: 0.025 },
68
- { match: /^gpt-5/i, inputPerM: 1.25, outputPerM: 10, cacheReadPerM: 0.125 },
113
+ // developers.openai.com/api/docs/models/gpt-5-nano, 2026-08-25. Before 0.9.6
114
+ // this fell through to the ^gpt-5 fallback and billed at $1.25/$10 — 25x the
115
+ // real rate in both directions. No published long-context tier.
116
+ { match: /^gpt-5-nano/i, inputPerM: 0.05, outputPerM: 0.4, cacheReadPerM: 0.005 },
117
+ // GPT-5 base and its dash-suffixed snapshots only. The `-|$` boundary stops
118
+ // this fallback from swallowing dot-minor families it knows nothing about:
119
+ // gpt-5.7-*, gpt-5.6-cyber and any future gpt-5.6-<variant> now return
120
+ // undefined -> "missing" instead of silently billing at GPT-5's $1.25/$10.
121
+ { match: /^gpt-5(?:-|$)/i, inputPerM: 1.25, outputPerM: 10, cacheReadPerM: 0.125 },
69
122
  { match: /^gpt-4\.1-nano/i, inputPerM: 0.1, outputPerM: 0.4 },
70
123
  { match: /^gpt-4\.1-mini/i, inputPerM: 0.4, outputPerM: 1.6 },
71
124
  { match: /^gpt-4\.1/i, inputPerM: 2, outputPerM: 8, cacheReadPerM: 0.5 },
@@ -122,6 +175,23 @@ const pricingRules = [
122
175
  // - deepseek-v4-* (api-docs.deepseek.com/quick_start/pricing): published
123
176
  // rates are time-of-day (off-peak = half price, up to 2x swing), so any
124
177
  // flat number here would be dishonest; needs timestamp-aware pricing.
178
+ //
179
+ // Deliberate deferrals (2026-08-25 OpenAI review), same honest path:
180
+ // - gpt-5.6-cyber / gpt-5.5-cyber ($12.50/$1.25/$75 on the pricing page):
181
+ // the two canonical sources disagree on whether the >272K tier applies —
182
+ // the pricing page omits cyber from its long-context list while
183
+ // developers.openai.com/api/docs/models/gpt-5.6-cyber states the 2x/1.5x
184
+ // rule does apply. Access is gated behind the Daybreak program, so the
185
+ // cost of leaving it unpriced is near zero and a coin-flip on the tier
186
+ // would be a real number that is wrong on long requests.
187
+ // - gpt-5.5-pro / gpt-5.4-pro: listed as long-context-capable but no
188
+ // per-model rate is published on either canonical source.
189
+ // - bare gpt-5.1 / gpt-5.3: the pricing page quotes the 5/5.1/5.2 group as a
190
+ // RANGE ($1.25-$1.75) and neither has a resolved per-model figure. Their
191
+ // -codex and -mini variants keep their own verified rules above.
192
+ // - gpt-5.6-codex / gpt-5.5-mini: NOT OpenAI model ids (both 404 on the model
193
+ // docs and are absent from developers.openai.com/api/docs/models). They
194
+ // appear only in this repo's fixtures and sample CSVs.
125
195
  ];
126
196
  export function findPricingRule(model) {
127
197
  return pricingRules.find((rule) => rule.match.test(model));
@@ -139,11 +209,20 @@ export function estimateTokenCostUsd(model, usage) {
139
209
  * models whose entire request moves to a higher rate above a prompt-size
140
210
  * threshold; pricing a daily token sum would incorrectly treat many small
141
211
  * requests as one large request.
212
+ *
213
+ * `tierPromptTokens[i]`, when provided, fixes the tier of `usages[i]` from
214
+ * request-level evidence instead of the slice's own prompt total. A
215
+ * session-cumulative slice is a sum of many requests whose prompt total
216
+ * routinely clears a per-request threshold on cache reads alone, even though no
217
+ * single request did; supplying the largest single request's prompt keeps such
218
+ * a slice on the base tier (and pricing it there is exact, since the base rate
219
+ * distributes over the sum). Omitting the array preserves single-request
220
+ * behaviour: each slice's own prompt selects its tier.
142
221
  */
143
- export function estimateTokenCostsUsd(model, usages) {
222
+ export function estimateTokenCostsUsd(model, usages, tierPromptTokens) {
144
223
  let total = 0;
145
- for (const usage of usages) {
146
- const usd = rawTokenCostUsd(model, usage);
224
+ for (let index = 0; index < usages.length; index += 1) {
225
+ const usd = rawTokenCostUsd(model, usages[index], tierPromptTokens?.[index]);
147
226
  if (usd === undefined)
148
227
  return undefined;
149
228
  total += usd;
@@ -162,21 +241,34 @@ export function promptTierThreshold(model) {
162
241
  * Tiered prices are selected per request, never from a multi-request sum.
163
242
  * An aggregate is still unambiguous when its entire non-negative prompt-side
164
243
  * total is at or below the threshold; then no constituent request can have
165
- * crossed it. Larger aggregates fail closed until request-level evidence is
166
- * available.
244
+ * crossed it. It is also unambiguous when request-level evidence
245
+ * (`maxRequestPromptTokens`, the largest single request the aggregate contains)
246
+ * proves that no constituent request crossed the threshold: every request was
247
+ * base-tier, so the whole sum is base-tier and prices exactly at the base rate.
248
+ * Larger aggregates without such evidence fail closed to keep an unpriceable
249
+ * total honestly "missing" rather than guessing a tier.
167
250
  */
168
- export function canPriceTokenUsageAtScope(model, usage, scope) {
251
+ export function canPriceTokenUsageAtScope(model, usage, scope, maxRequestPromptTokens) {
169
252
  const threshold = promptTierThreshold(model);
170
253
  if (threshold === undefined || scope === "request")
171
254
  return true;
172
- return effectivePromptTokens(usage) <= threshold;
255
+ if (effectivePromptTokens(usage) <= threshold)
256
+ return true;
257
+ return maxRequestPromptTokens !== undefined && maxRequestPromptTokens <= threshold;
173
258
  }
174
- function rawTokenCostUsd(model, usage) {
259
+ /**
260
+ * @param tierPromptTokens Prompt size used ONLY to select the request tier,
261
+ * when it differs from the priced slice's own prompt total (e.g. a
262
+ * session-cumulative slice whose tier is fixed by its largest single
263
+ * request). Component pricing always uses `usage`; defaults to the slice's
264
+ * own effective prompt so single-request callers are unchanged.
265
+ */
266
+ function rawTokenCostUsd(model, usage, tierPromptTokens) {
175
267
  const rule = findPricingRule(model);
176
268
  if (!rule) {
177
269
  return undefined;
178
270
  }
179
- const promptTokens = effectivePromptTokens(usage);
271
+ const promptTokens = tierPromptTokens ?? effectivePromptTokens(usage);
180
272
  const rates = rule.abovePromptTokens &&
181
273
  promptTokens > rule.abovePromptTokens.threshold
182
274
  ? rule.abovePromptTokens