feat(pricing): model above-threshold price cliffs (incl. OpenAI's 272k), clear all dependency advisories, SHA-pin actions - #29
Conversation
…ot a hardcoded list parseTiers only looked for `_above_128k_tokens` and `_above_200k_tokens`. Upstream now ships five thresholds (128k, 200k, 256k, 272k, 512k), so 42 models were priced flat above their cliff. OpenAI documents gpt-5.6-sol as 2x input and 1.5x output for the FULL request past 272k input tokens, which means a 275k-token prompt was being understated by 50% on input. Discover thresholds from each entry's own keys instead. A fixed list is the same stale-literal failure mode as the pinned-SHA test assertion and the dated provenance comment: it goes silently wrong the moment a provider adds a tier. Guards kept: only the bare `_above_<N>k_tokens` suffix matches, so `_flex`, `_priority` and the `_above_1hr_above_<N>k` cache-write variant stay out; the threshold must land in 1k..100M; every rate still passes sanePrice; results are sorted ascending so the generated artifact stays byte-stable (A11). Models carrying at least one tier: 62 -> 106. Anchor prices untouched, so the hand-computed E2E oracles are unaffected.
…verrides - dompurify 3.4.11 -> 3.4.13 (runtime, GHSA-c2j3-45gr-mqc4). Transitive via jspdf. Nothing calls jspdf's html() path so the sanitizer bypass was unreachable here, but DOMPurify still ships in the bundle, so patch rather than argue about it. - postcss 8.5.16 -> 8.5.18 (dev, path traversal in source-map auto-loading). - brace-expansion: three distinct instances at 1.1.15, 2.1.1 and 5.0.7, each with its own advisory range. Version-scoped override keys pin each to its own fixed line (1.1.18 / 2.1.4 / 5.0.9); a blanket override would have downgraded the 5.x instance under vitest coverage to a 2.x that its dependents do not expect. npm audit reports 0 vulnerabilities with and without dev deps. Full gate, build, size budget, wasm/claims/first-paint/policy checks all pass.
…dings) Tags are mutable. An action owner can silently repoint v5 at new code, which is how the trivy-action and kics-github-action compromises landed. This was already a known deferred item (the D13 note in ci.yml) and the stakes just went up: refresh-pricing now mints a Contents:RW + PullRequests:RW App token and pushes a branch that auto-merges to main and auto-deploys to tokentally.ai. A repointed create-github-app-token tag is a direct path to production. Pinned to the majors already exercised here (checkout v5, setup-node v5, create-github-app-token v2) rather than jumping to the newer majors that exist, since the finding is about mutability and a version jump is a separate change. Pinning ci.yml at v5 also retires the Node 20 deprecation warning. semgrep p/typescript + p/react + p/security-audit + p/github-actions + p/secrets now reports 0 findings. gitleaks: no leaks across 177 commits. trivy fs: clean.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR hardens TokenTally’s pricing correctness and deployment safety by (1) making above-threshold “price cliff” tier discovery data-driven (so new upstream thresholds like 272k are not silently missed), (2) clearing reported dependency advisories via targeted upgrades/overrides, and (3) SHA-pinning GitHub Actions used in CI and the pricing refresh workflow.
Changes:
- Discover and parse tier thresholds from upstream entry keys (supporting additional thresholds such as 256k/272k/512k), with tests covering sorting, filtering, and service-tier exclusions.
- Add “shipped registry” wiring tests to ensure tier cliffs reprice the whole request as intended by the effective-rate functions.
- Update dependencies/overrides to clear advisories and SHA-pin key GitHub Actions in CI + refresh workflow.
Reviewed changes
Copilot reviewed 6 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/registry/normalize.ts | Replaces hardcoded tier threshold list with key-driven threshold discovery and uses it in parseTiers. |
| src/registry/tests/parseTiers.test.ts | Adds coverage for discovered thresholds (e.g., 272k), ordering stability, and ignoring service-tier variants. |
| src/engine/cost/tests/rates.test.ts | Adds tests against the shipped registry artifact to validate “whole request repricing” semantics for tiers. |
| package.json | Updates postcss and adds advisory-clearing overrides (DOMPurify, brace-expansion, postcss). |
| package-lock.json | Locks updated dependency graph reflecting advisory-clearing versions. |
| .github/workflows/refresh-pricing.yml | SHA-pins actions (notably token-minting step) used in automated pricing refresh. |
| .github/workflows/ci.yml | SHA-pins checkout/setup-node actions in CI to prevent mutable-tag drift. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Verified each claim against the shipped catalog before changing anything. Correctness: - rates.ts resolved every rate from ONE winning tier, so a higher PARTIAL tier shadowed a lower COMPLETE one and the rate fell all the way back to base. With tiers [200k input $6] and [272k cache-only], a 300k request billed input at the $3 base. Probed and confirmed: 50% understatement. Rates now resolve per field against the highest crossed tier that actually defines that field. - engine/index.ts computed a tier-aware cache-write rate and then ignored it, reading the base CacheSpec field instead. Every above-cliff write was billed at base (2x under on min5), and on hr1 the base 5-minute rate no longer matched base*1.25 against the tier-aware input, so the C6 conformance check returned null and fell back to base again (3.2x under). writeRateForTtl now takes the tier-aware 5-minute rate. Auto-merge safety, which the tier work made urgent: - The refresh had no visibility into tier changes at all. The anchor fingerprint covers gpt-4o and gpt-4o-mini, neither of which has a tier, so upstream editing an above-threshold rate would have repriced forecasts up to 2x, stayed inside the path allowlist, gone green and auto-deployed. A tier change on an already-shipped model now blocks auto-merge and labels the PR. - Threshold-bearing keys the parser cannot read are counted into the snapshot as unparsedTierKeyCount (0 today) and an increase also blocks auto-merge, so a new upstream label shape is loud instead of silently pricing a model flat. My own tests, both of which would have broken CI on a correct registry and blocked the weekly auto-merge: - The catalog test asserted the below-threshold rate equals the BASE rate, which is false as soon as any model carries two tiers. It now re-derives the expected rate independently. - It also asserted magic counts (>2 distinct thresholds, >60 tiered models) that are pure functions of live upstream data, where 256k and 512k each come from a single model. Replaced with structural invariants. - The MAX_THRESHOLD_TOKENS guard was unreachable behind a 7-digit regex cap and its test was actually being rejected by the regex. Cap removed, guard now covered, and a digits-only label is documented as the thing keeping an upstream key out of a prototype-pollution-shaped property read. Also: brace-expansion 3.x had no override while 1.x/2.x/>=4 did, and GHSA-mh99-v99m-4gvg covers >=3.0.0 <3.0.3. Nothing resolves 3.x today, so the gap was invisible. Six stale "128k/200k" comments updated, and the security rationale in parseTiers that still claimed keys come from a hardcoded list.
Review note: casting registrySnapshot.models to ModelRecord[] skipped the RegistrySnapshot shape check the rest of the codebase goes through.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 19 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/registry/normalize.ts:137
countUnparsedTierKeysis meant to make any unreadable_above_*_tokenskey loud, butUNPARSED_TIER_KEY = /_above_[^_]*_tokens$/only matches labels with no underscores. That means keys like_above_1hr_above_200k_tokens(mentioned in the comment) or any future threshold label containing_will never be counted, defeating the purpose of surfacing new upstream shapes.
// Any `_above_*_tokens` key whose label this parser does NOT understand. Counted rather than ignored: a
// label shape we cannot read (`_above_1m_tokens`, a raw token count) reproduces the exact silent
// flat-pricing bug this module exists to prevent, and silence is how it stayed hidden last time.
// Deliberately excludes the service-tier and long-TTL variants, which are known-out-of-scope, not missed.
const UNPARSED_TIER_KEY = /_above_[^_]*_tokens$/;
scripts/registry/refresh.mjs:101
- The comment says “Models that are NEW this refresh are not flagged”, but
unparsedUpis computed from the snapshot-wideunparsedTierKeyCountand will also flip true if added models introduce unreadable tier keys (even though they were never priced before). Either adjust the logic to exclude added models or update the comment so the workflow behavior is not misleading.
// Tier deltas are invisible to the anchor fingerprint (neither gpt-4o nor gpt-4o-mini has a tier), yet a
// changed above-threshold rate reprices a long-context forecast by up to 2x. Since the refresh now
// auto-merges and auto-deploys, any tier change on a model we already shipped, or any newly unreadable
// threshold key, has to stop the unattended path and wait for a human. Models that are NEW this refresh are
// not flagged: they are already listed in the added section and were never priced before.
const tierFingerprint = (snap) =>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 19 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/engine/caching/policy.ts:50
- The comment says the 1‑hour write rate is derived from the “BASE input rate”, but callers now pass the tier-aware effective input rate (and may pass a tier-aware 5‑min write rate). Updating this wording would avoid confusion about which input rate the C6 conformance check uses.
// C6: derive the 1-hr rate from the BASE input rate, not by scaling the stored 5-min field - but only
// when the stored 5-min rate actually equals base*1.25; a non-conforming SKU returns null (unknown),
// never a fabricated guess.
…gs behind them The three items deferred out of the tier PR. 1. tierStraddle was computed on every forecast and never shown. A user crossing the 272k cliff got correct math and no explanation, and no hint that trimming a few thousand tokens halves the bill, which is the exact optimization this product claims to surface. Forecasts now carry tierThresholds (the crossed, rate-changing thresholds), and the result surface names the cliff and explains that the provider reprices the ENTIRE request above it. The caveat also travels into the CSV and PDF exports, since an exported number without it is misleading on its own. Cache-only tiers are excluded: crossing one changes no rate the user can act on, so reporting it would be a false signal. 2. perCharacterForecast compared a CHARACTER count against a token-denominated threshold. Upstream names the field input_cost_per_character_above_128k_TOKENS, so the rate is per character while the threshold is in tokens, and every cliff tripped roughly 4x too early. Converted with the same Latin-text ratio the tokenizer heuristic uses. Zero dollar impact today only because both per_character models carrying tiers are priced at 0. 3. Upstream publishes a real 1-hour cache-write rate for 124 SKUs, and a real above-cliff one for 10, and we discarded both while deriving hr1 from a multiplier that policy.ts itself labels UNVERIFIED. Above a cliff that derivation did not even fire: the base 5-minute rate no longer matched base*1.25 against the tier-aware input, so it returned null and fell back to base. claude-sonnet-4-5 above 200k was billed $3.75/M against upstream's real $12/M. Both rates are now parsed and preferred, with the derivation kept only as the fallback for SKUs that publish nothing. 398 tests (up from 393). Gates green: lint, both tsconfigs, build, size (15.74 kB against the 16 kB first-paint budget), wasm-free, honest-claims, first-paint-lean, policy-fresh, npm audit 0, semgrep 0.
…g it Review note: gating on an INCREASE meant that once any unreadable threshold key merged, every later refresh would auto-merge again while the snapshot still carried keys we cannot price. The invariant is zero, so any non-zero count now holds the PR for a human.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 25 out of 27 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/workloads/tiers.ts:68
- The
crossedThresholdsdoc comment says cache-only tiers are excluded only when the workload has no cache, but the implementation always excludes cache-only tiers (it only considers tiers withinputPrice/outputPrice). This mismatch is likely to confuse future callers/maintainers; either include cache tiers conditionally or adjust the comment to match current behavior.
// The distinct thresholds the accumulation actually crosses between unit 1 and unit `units`, ascending.
// Only tiers that change a rate count: a cache-only tier crossed by a workload with no cache is not a
// price cliff the user can act on, and reporting it would be a false signal.
src/engine/cost/tests/rates.test.ts:128
- The shipped-registry wiring test treats a tier as non-empty only if it defines input/output/cacheRead/cacheWrite, but
parseTierscan now emit a tier that only definescacheWriteHr1PerMToken. That would incorrectly fail this test when such a tier appears in the catalog.
const definesSomething =
t.inputPrice !== null ||
t.outputPrice !== null ||
t.cacheReadPerMToken !== undefined ||
t.cacheWritePerMToken !== undefined;
src/workloads/tiers.ts:91
detectStraddlecurrently usestierFor(..., model.tiers), which will treat cache-only tiers as straddles even when no input/output rate changes. That can maketierStraddletrue whiletierThresholdsis empty (sincecrossedThresholdsexcludes cache-only tiers) and can trigger unnecessary banded correction work. Consider filtering to tiers that can affect input/output rates so the flags and correction trigger stay aligned.
// True when unit 1 and unit `units` select different tiers (the accumulation crosses a threshold).
export function detectStraddle(
model: ModelRecord,
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 25 out of 27 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/engine/cost/tests/rates.test.ts:129
- The “none is an empty shell” assertion doesn’t consider the newly added
cacheWriteHr1PerMTokentier field. If upstream ever ships a threshold tier that only publishes the 1‑hour write rate (withoutcacheWritePerMToken), this test will fail even though the tier is meaningful. IncludecacheWriteHr1PerMTokenindefinesSomething.
const definesSomething =
t.inputPrice !== null ||
t.outputPrice !== null ||
t.cacheReadPerMToken !== undefined ||
t.cacheWritePerMToken !== undefined;
src/workloads/forecast.ts:60
CHARS_PER_TOKEN_FOR_TIERSduplicatesASCII_CHARS_PER_TOKENfromsrc/tokenizer/heuristic.ts(and the comment says they must match). Keeping these as separate literals risks silent drift if the heuristic constants change, which would reintroduce tier-threshold unit mismatch forper_charactermodels. Consider exporting a shared constant (or a small shared helper) and importing it here.
// Latin-text characters per token, matching ASCII_CHARS_PER_TOKEN in the tokenizer heuristic. Used ONLY to
// put a per_character character count into the token unit that tier thresholds are specified in.
const CHARS_PER_TOKEN_FOR_TIERS = 4;
Three related changes, all on the path to a correct and defensible production deploy.
1. Above-threshold price cliffs were silently ignored
parseTiersinsrc/registry/normalize.tslooked for exactly two hardcoded threshold labels,_above_128k_tokensand_above_200k_tokens. Upstream ships five (128k, 200k, 256k, 272k, 512k), so 42 models were priced completely flat above their cliff.The concrete case: OpenAI documents
gpt-5.6-solas 2x input and 1.5x output for the full request past 272,000 input tokens (docs). The upstream data agrees ($5 -> $10 input, $30 -> $45 output). We were charging the base rate.Measured impact on a 10,000-call/month forecast:
A 1.9% larger prompt doubles the bill. We were understating it by 50% on input.
Fix: discover thresholds from each entry's own keys instead of a fixed list. A hardcoded list is the same stale-literal failure mode as the pinned-SHA test assertion (#26) and the dated provenance comment (#27): it goes silently wrong the moment a provider adds a tier, and it fails toward under-reporting cost, which is the worst direction for this product.
Guards retained: only the bare
_above_<N>k_tokenssuffix matches, so_flex,_priorityand the_above_1hr_above_<N>kcache-write variant stay out; thresholds must land in 1k..100M; every rate still passessanePrice; output is sorted ascending so the artifact stays byte-stable (A11).Models carrying at least one tier: 62 -> 106.
droppedCountunchanged at 585. Anchor prices untouched, so the hand-computed E2E oracles are unaffected.The cliff semantics were already correct in
rates.ts(effectiveInputRatereprices the whole request, not marginally) — only discovery was broken.2. All four dependency advisories cleared
dompurify3.4.11 -> 3.4.13 (runtime, GHSA-c2j3-45gr-mqc4). Transitive via jspdf. Nothing calls jspdf'shtml(), so the bypass was unreachable, but DOMPurify still ships in the bundle.postcss8.5.16 -> 8.5.18 (dev, path traversal).brace-expansion: three instances at 1.1.15 / 2.1.1 / 5.0.7, each with its own advisory range. Version-scoped override keys pin each to its own line. A blanket override would have downgraded the 5.x instance under vitest coverage.npm audit: 0 vulnerabilities, with and without dev deps.3. Every GitHub Action SHA-pinned
semgrep
p/github-actionsflagged 5 mutable-tag findings. Already a known deferred item (the D13 note in ci.yml), and the stakes rose this morning:refresh-pricingnow mints a Contents:RW App token and pushes a branch that auto-merges to main and auto-deploys. A repointedcreate-github-app-tokentag is a direct path to production.Verification
npm run test:ci: 65 files, 389 tests (up from 381), both tsconfigs.Known gap, not addressed here
tierStraddleis computed on every forecast and never rendered. A user whose workload crosses the 272k cliff now gets correct math but no explanation of the 2x jump, and no prompt that trimming a few thousand tokens halves the bill. That is the single largest optimization lever this change unlocks. Deliberately left for a follow-up rather than expanded into this PR.