Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
a27f990
Add token unit registry spec, reference, and implementation plan
alexmojaki Mar 23, 2026
8350c58
Add token unit registry types and family definition
alexmojaki Mar 23, 2026
b538e70
Add containment helpers and leaf value decomposition via Mobius inver…
alexmojaki Mar 23, 2026
887d11a
Rewire calc_price to use registry-driven decomposition
alexmojaki Mar 23, 2026
0e3d754
Update test dataset for decomposition bugfix
alexmojaki Mar 23, 2026
0db8300
Remove plan and reference doc from branch (keep only spec)
alexmojaki Mar 23, 2026
6a72346
Add unit registry YAML as source of truth
alexmojaki Mar 23, 2026
3e935dd
Add build step to generate units JSON from YAML
alexmojaki Mar 23, 2026
6762c25
Load unit definitions from generated JSON instead of hardcoding
alexmojaki Mar 23, 2026
0c7e221
Add JS unit registry types and loader
alexmojaki Mar 23, 2026
3303203
Add JS decomposition engine with Mobius inversion
alexmojaki Mar 23, 2026
9ce58b4
Rewire JS calcPrice to use registry-driven decomposition
alexmojaki Mar 23, 2026
5ac6b8a
Validate unit definitions with Pydantic
alexmojaki Mar 23, 2026
afd7bd9
Symlink JS units JSON, add YAML schema, document prettier step
alexmojaki Mar 23, 2026
9409945
Document ancestor coverage precondition on compute_leaf_values
alexmojaki Mar 23, 2026
a599386
Replace AbstractUsage Protocol with object type alias
alexmojaki Mar 23, 2026
06d9710
Add ancestor coverage validation on ModelPrice construction, fix 3 Op…
alexmojaki Mar 23, 2026
14aba2b
Update test fixtures for ancestor coverage validation on audio models
alexmojaki Mar 23, 2026
6507696
Add ancestor coverage validation in JS calcPrice
alexmojaki Mar 23, 2026
4fb8596
Rename cache_read_audio_mtok unit ID to cache_audio_read_mtok to matc…
alexmojaki Mar 23, 2026
d2e63a1
Remove FIELD_TO_UNIT identity mapping, use TOKENS_FAMILY.units directly
alexmojaki Mar 23, 2026
3879dd2
Replace phase language with concrete descriptions, fix spec naming to…
alexmojaki Mar 23, 2026
51e7957
Restore computed variable assertions in cache tokens test
alexmojaki Mar 23, 2026
6ecaea0
Replace units-data.json symlink with file copy
alexmojaki Mar 23, 2026
b0cb6d8
Fix total_input_tokens to handle Mapping-type usage via get_usage_value
alexmojaki Mar 23, 2026
fb8de9f
Update spec: eliminate phases, require data-driven ModelPrice
alexmojaki Mar 23, 2026
570024c
Add initial data-driven unit registry spec: axioms and derived princi…
alexmojaki Mar 30, 2026
0722067
Spec: add unit distribution requirement and explicit validation rules
alexmojaki Mar 30, 2026
7a7d4e4
Spec: add pricing accuracy as top-level goal, distinguish price vs us…
alexmojaki Mar 30, 2026
1f298fa
Spec: add unit naming convention, acknowledge cache_audio_read incons…
alexmojaki Mar 30, 2026
2497cff
Spec: replace naming convention with writability principle and schema…
alexmojaki Mar 30, 2026
298ac74
Rename cache units to cache_{modality}_{op} pattern (mechanical)
alexmojaki Mar 30, 2026
ec247e1
Spec: add dimension-filtered aggregate counts
alexmojaki Mar 30, 2026
797982d
Spec: add inconsistent usage rejection and price sanity checks
alexmojaki Mar 30, 2026
501a4ea
Spec: add derived requirements — runtime mechanics, requests_kcount, …
alexmojaki Mar 30, 2026
27f6bc0
Spec: add missing items from old spec — usage key default, decomposit…
alexmojaki Mar 30, 2026
5cfc5a6
Remove old unit registry spec, superseded by specs/data-driven-unit-r…
alexmojaki Mar 30, 2026
d916fac
Spec: address review issues — fix forward ref, citations, examples, a…
alexmojaki Mar 30, 2026
6c7069e
Spec: address second review — fix forward ref, join ambiguity, reques…
alexmojaki Mar 30, 2026
7d27d0f
Spec: fix requests_kcount usage key — concrete value, not vague
alexmojaki Mar 30, 2026
a1419d6
Spec: note custom unit API as open question
alexmojaki Mar 30, 2026
0516d37
Spec: add validation performance constraint — expensive checks at def…
alexmojaki Mar 30, 2026
3d7b73c
Spec: clarify data.py vs data.json, validation trust is about provena…
alexmojaki Mar 30, 2026
894ccaa
Spec: note JS package also has generated data file alongside Python
alexmojaki Mar 30, 2026
d725400
algorithm.md: clarify depth = number of dimensions, no zero-dimension…
alexmojaki Mar 30, 2026
0f0894e
Spec: address third review — forward ref, citations, TieredPrices sco…
alexmojaki Mar 30, 2026
619c4bb
Spec: address fourth review — unpriced family ignored, zero aggregate…
alexmojaki Mar 30, 2026
31fee35
Resolve open question: custom units arrive via DataSnapshot
alexmojaki Mar 30, 2026
4acaaf9
Simplify custom unit API: families live in snapshot, no separate regi…
alexmojaki Mar 30, 2026
045dfc4
Add spec item: Mapping usage keys validated against registry
alexmojaki Mar 30, 2026
770ad2f
Merge branch 'main' into feat/token-unit-registry
alexmojaki Mar 31, 2026
a6b7a1b
Spec: data.json becomes top-level dict, auto-update validation errors…
alexmojaki Mar 31, 2026
f623439
Spec: one global snapshot, lazy validation at set_custom_snapshot, fi…
alexmojaki Mar 31, 2026
db025b3
Spec review: fix forward references, stale citations, move validation…
alexmojaki Mar 31, 2026
c67b1ee
Spec: add explicit items for API signature preservation and calc/extr…
alexmojaki Mar 31, 2026
7c1b72b
Spec: global usage key uniqueness, algorithm-validation dependency, t…
alexmojaki Mar 31, 2026
f7c8965
Spec: soften aggregate queries to nice-to-have, clarify as usage-only…
alexmojaki Mar 31, 2026
beb7b58
Spec: separate cost aggregation (required) from count aggregation (ni…
alexmojaki Mar 31, 2026
e1eae3e
Spec review: fix forward reference, move decomposition-correctness af…
alexmojaki Mar 31, 2026
e1fa5cb
Spec: address review — inference for incomplete usage, extractors in …
alexmojaki Apr 2, 2026
7bf7fed
Spec: add registry join-closedness validation rule for custom families
alexmojaki Apr 2, 2026
46b8f08
Spec: DataSnapshot optional unit_families param; use 'unit families' …
alexmojaki Apr 2, 2026
9ccccb1
Algorithm doc: fix 'product of chains' to 'product of flat lattices'
alexmojaki Apr 2, 2026
74e070a
Spec: add calc_price performance constraint — dict lookups only, no h…
alexmojaki Apr 2, 2026
d48bfa4
Spec: error messages must describe data problems, not algorithm inter…
alexmojaki Apr 2, 2026
b5d4fa8
Spec: remove auto-update retry item — existing hourly retry is fine
alexmojaki Apr 2, 2026
96ed2bb
Spec: requests usage defaults to 1 per call, goes through normal deco…
alexmojaki Apr 2, 2026
9d0a21c
Spec: UnitRegistry architecture — single class owns all unit state, m…
alexmojaki Apr 2, 2026
3c4d6d2
Add code spec: architecture skeleton for data-driven unit registry
alexmojaki Apr 2, 2026
cb6037b
Code spec: address review — total_input_tokens, JSON schema, extracto…
alexmojaki Apr 2, 2026
05654b1
Code spec: add explicit constraints — no algorithm jargon in errors, …
alexmojaki Apr 2, 2026
acaf993
Code spec: total_input_tokens from sum of input leaf values, not raw …
alexmojaki Apr 2, 2026
833de9b
Code spec: use dataclasses for UnitDef/UnitFamily, drop __slots__ eve…
alexmojaki Apr 2, 2026
c696d5a
Spec: UnitDef and UnitFamily are dataclasses, not plain classes
alexmojaki Apr 2, 2026
ed73e94
Code spec: slim UnitRegistry API — public dicts, cut getter methods, …
alexmojaki Apr 2, 2026
025f7c1
Code spec: use concrete type in calc_price signature, not AbstractUsage
alexmojaki Apr 2, 2026
914690c
Code spec: calc_price accepts object, not AbstractUsage — getattr on …
alexmojaki Apr 2, 2026
735d88f
Spec: expand reasoning for usage type being object, not AbstractUsage
alexmojaki Apr 2, 2026
98904f3
Code spec: fix is_free() to check all values zero/None, not just empt…
alexmojaki Apr 2, 2026
7cf7409
Spec: price sanity checks start as errors, not warnings
alexmojaki Apr 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ repos:
rev: v2.3.0
hooks:
- id: codespell
exclude: "^tests/cassettes/"
additional_dependencies:
- tomli

Expand Down
8 changes: 6 additions & 2 deletions packages/js/src/__tests__/calcPrice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,18 @@ describe('Core Price Calculation Function', () => {
}
const modelPrice: ModelPrice = {
input_audio_mtok: 10.0,
input_mtok: 5.0,
output_audio_mtok: 20.0,
output_mtok: 10.0,
}

const result = calcPrice(usage, modelPrice)

// input leaf: 100 - 100 = 0 non-audio text, 100 audio
// output leaf: 50 - 50 = 0 non-audio text, 50 audio
expect(result).toMatchObject({
input_price: 0.001, // 100 * 10.0 / 1_000_000
output_price: 0.001, // 50 * 20.0 / 1_000_000
input_price: 0.001, // (0 * 5.0 + 100 * 10.0) / 1_000_000
output_price: 0.001, // (0 * 10.0 + 50 * 20.0) / 1_000_000
total_price: 0.002,
})
})
Expand Down
176 changes: 176 additions & 0 deletions packages/js/src/__tests__/decompose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest'

import type { Usage } from '../types'

import { computeLeafValues, isDescendantOrSelf, validateAncestorCoverage } from '../decompose'
import { getUnit, TOKENS_FAMILY } from '../units'

describe('Containment', () => {
it('self is descendant-or-self', () => {
const unit = getUnit('input_mtok')
expect(isDescendantOrSelf(unit, unit)).toBe(true)
})

it('child is descendant', () => {
expect(isDescendantOrSelf(getUnit('input_mtok'), getUnit('cache_read_mtok'))).toBe(true)
})

it('parent is NOT descendant of child', () => {
expect(isDescendantOrSelf(getUnit('cache_read_mtok'), getUnit('input_mtok'))).toBe(false)
})

it('grandchild is descendant', () => {
expect(isDescendantOrSelf(getUnit('input_mtok'), getUnit('cache_audio_read_mtok'))).toBe(true)
})

it('sibling not descendant', () => {
expect(isDescendantOrSelf(getUnit('cache_read_mtok'), getUnit('cache_write_mtok'))).toBe(false)
})

it('different direction not descendant', () => {
expect(isDescendantOrSelf(getUnit('input_mtok'), getUnit('output_mtok'))).toBe(false)
})
})

describe('Leaf Values', () => {
const family = TOKENS_FAMILY

it('simple text model', () => {
const priced = new Set(['input_mtok', 'output_mtok'])
const usage = { input_tokens: 1000, output_tokens: 500 }
expect(computeLeafValues(priced, usage, family)).toEqual({ input_mtok: 1000, output_mtok: 500 })
})

it('with cache', () => {
const priced = new Set(['cache_read_mtok', 'cache_write_mtok', 'input_mtok', 'output_mtok'])
const usage = { cache_read_tokens: 200, cache_write_tokens: 100, input_tokens: 1000, output_tokens: 500 }
expect(computeLeafValues(priced, usage, family)).toEqual({
cache_read_mtok: 200,
cache_write_mtok: 100,
input_mtok: 700,
output_mtok: 500,
})
})

it('with audio', () => {
const priced = new Set(['input_audio_mtok', 'input_mtok', 'output_mtok'])
const usage = { input_audio_tokens: 300, input_tokens: 1000, output_tokens: 500 }
expect(computeLeafValues(priced, usage, family)).toEqual({
input_audio_mtok: 300,
input_mtok: 700,
output_mtok: 500,
})
})

it('lattice: cache_read_audio carved from both', () => {
const priced = new Set(['cache_audio_read_mtok', 'cache_read_mtok', 'input_audio_mtok', 'input_mtok'])
const usage = {
cache_audio_read_tokens: 50,
cache_read_tokens: 200,
input_audio_tokens: 300,
input_tokens: 1000,
}
expect(computeLeafValues(priced, usage, family)).toEqual({
cache_audio_read_mtok: 50,
cache_read_mtok: 150,
input_audio_mtok: 250,
input_mtok: 550,
})
})

it('unpriced audio stays in catch-all', () => {
const priced = new Set(['cache_read_mtok', 'input_mtok', 'output_mtok'])
const usage = {
cache_read_tokens: 200,
input_audio_tokens: 300,
input_tokens: 1000,
output_tokens: 500,
}
expect(computeLeafValues(priced, usage, family)).toEqual({
cache_read_mtok: 200,
input_mtok: 800,
output_mtok: 500,
})
})

it('unpriced cache stays in catch-all', () => {
const priced = new Set(['input_mtok', 'output_mtok'])
const usage = { cache_read_tokens: 200, input_tokens: 1000, output_tokens: 500 }
expect(computeLeafValues(priced, usage, family)).toEqual({
input_mtok: 1000,
output_mtok: 500,
})
})

it('negative leaf raises error', () => {
const priced = new Set(['cache_read_mtok', 'input_mtok'])
const usage = { cache_read_tokens: 200, input_tokens: 100 }
expect(() => computeLeafValues(priced, usage, family)).toThrow(/Negative leaf value.*input_mtok/)
})

it('full 7-unit model', () => {
const priced = new Set([
'cache_audio_read_mtok',
'cache_read_mtok',
'cache_write_mtok',
'input_audio_mtok',
'input_mtok',
'output_audio_mtok',
'output_mtok',
])
const usage = {
cache_audio_read_tokens: 50,
cache_read_tokens: 200,
cache_write_tokens: 100,
input_audio_tokens: 300,
input_tokens: 1000,
output_audio_tokens: 150,
output_tokens: 800,
}
expect(computeLeafValues(priced, usage, family)).toEqual({
cache_audio_read_mtok: 50,
cache_read_mtok: 150,
cache_write_mtok: 100,
input_audio_mtok: 250,
input_mtok: 450,
output_audio_mtok: 150,
output_mtok: 650,
})
})

it('accepts Usage object (optional fields)', () => {
const priced = new Set(['cache_read_mtok', 'input_mtok', 'output_mtok'])
const usage: Usage = { cache_read_tokens: 200, input_tokens: 1000, output_tokens: 500 }
expect(computeLeafValues(priced, usage as Record<string, unknown>, family)).toEqual({
cache_read_mtok: 200,
input_mtok: 800,
output_mtok: 500,
})
})
})

describe('Ancestor Coverage', () => {
it('valid: input + output', () => {
expect(() => {
validateAncestorCoverage(new Set(['input_mtok', 'output_mtok']), TOKENS_FAMILY)
}).not.toThrow()
})

it('valid: with cache', () => {
expect(() => {
validateAncestorCoverage(new Set(['cache_read_mtok', 'input_mtok', 'output_mtok']), TOKENS_FAMILY)
}).not.toThrow()
})

it('missing ancestor: cache_read without input', () => {
expect(() => {
validateAncestorCoverage(new Set(['cache_read_mtok', 'output_mtok']), TOKENS_FAMILY)
}).toThrow(/ancestor.*input_mtok/)
})

it('missing intermediate ancestor', () => {
expect(() => {
validateAncestorCoverage(new Set(['cache_audio_read_mtok', 'input_mtok', 'output_mtok']), TOKENS_FAMILY)
}).toThrow(/ancestor/)
})
})
60 changes: 60 additions & 0 deletions packages/js/src/__tests__/units.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'

import { getFamily, getUnit, TOKENS_FAMILY } from '../units'

describe('Unit Registry', () => {
it('should load the tokens family', () => {
const family = getFamily('tokens')
expect(family.id).toBe('tokens')
expect(family.per).toBe(1_000_000)
})

it('should have 20 token units', () => {
const family = getFamily('tokens')
expect(Object.keys(family.units)).toHaveLength(20)
})

it('should look up a unit by ID', () => {
const unit = getUnit('input_mtok')
expect(unit.familyId).toBe('tokens')
expect(unit.usageKey).toBe('input_tokens')
expect(unit.dimensions).toEqual({ direction: 'input' })
})

it('should throw on unknown unit', () => {
expect(() => getUnit('nonexistent')).toThrow()
})

it('should throw on unknown family', () => {
expect(() => getFamily('nonexistent')).toThrow()
})

it('should have all 7 currently-used units', () => {
for (const unitId of [
'input_mtok',
'output_mtok',
'cache_read_mtok',
'cache_write_mtok',
'input_audio_mtok',
'cache_audio_read_mtok',
'output_audio_mtok',
]) {
expect(TOKENS_FAMILY.units[unitId]).toBeDefined()
}
})

it('should have correct usage_key for cache_audio_read_mtok', () => {
const unit = getUnit('cache_audio_read_mtok')
expect(unit.usageKey).toBe('cache_audio_read_tokens')
})

it('should validate all unit dimensions against family dimensions', () => {
const family = getFamily('tokens')
for (const unit of Object.values(family.units)) {
for (const [dimKey, dimVal] of Object.entries(unit.dimensions)) {
expect(family.dimensions[dimKey]).toBeDefined()
expect(family.dimensions[dimKey]).toContain(dimVal)
}
}
})
})
7 changes: 7 additions & 0 deletions packages/js/src/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9432,7 +9432,9 @@ export const data: Provider[] = [
starts_with: 'gpt-4o-audio-preview',
},
context_window: 128000,
price_comments: 'input_mtok set equal to input_audio_mtok (audio-only input model, catch-all required by ancestor coverage rule)',
prices: {
input_mtok: 2.5,
output_mtok: 10,
input_audio_mtok: 2.5,
},
Expand Down Expand Up @@ -9483,7 +9485,9 @@ export const data: Provider[] = [
match: {
starts_with: 'gpt-4o-mini-audio',
},
price_comments: 'input_mtok set equal to input_audio_mtok (audio-only input model, catch-all required by ancestor coverage rule)',
prices: {
input_mtok: 0.15,
output_mtok: 0.6,
input_audio_mtok: 0.15,
},
Expand Down Expand Up @@ -9518,8 +9522,11 @@ export const data: Provider[] = [
match: {
equals: 'gpt-4o-mini-tts',
},
price_comments:
'output_mtok set equal to output_audio_mtok (audio-only output model, catch-all required by ancestor coverage rule)',
prices: {
input_mtok: 0.6,
output_mtok: 12,
output_audio_mtok: 12,
},
},
Expand Down
83 changes: 83 additions & 0 deletions packages/js/src/decompose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { UnitDef, UnitFamily } from './units'

/**
* True if candidate's dimensions are a (non-strict) superset of ancestor's.
*/
export function isDescendantOrSelf(ancestor: UnitDef, candidate: UnitDef): boolean {
if (ancestor.familyId !== candidate.familyId) return false
return Object.entries(ancestor.dimensions).every(([k, v]) => candidate.dimensions[k] === v)
}

/**
* Get a usage value by key from a plain object. Returns 0 for missing/undefined/null.
*/
function getUsageValue(usage: Record<string, unknown>, key: string): number {
const val = usage[key]
return typeof val === 'number' ? val : 0
}

/**
* Validate that every priced unit has all its ancestors also priced.
* Throws if any ancestor is missing.
*/
export function validateAncestorCoverage(pricedUnitIds: Set<string>, family: UnitFamily): void {
for (const unitId of pricedUnitIds) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const unit = family.units[unitId]!
for (const [otherId, other] of Object.entries(family.units)) {
if (otherId !== unitId && isDescendantOrSelf(other, unit) && !pricedUnitIds.has(otherId)) {
throw new Error(
`Unit '${unitId}' is priced but its ancestor '${otherId}' is not. ` + `All ancestors of a priced unit must also be priced.`
)
}
}
}
}

/**
* Compute leaf values for each priced unit via Mobius inversion on the containment poset.
*
* Only priced units participate. Unpriced units' tokens stay in the nearest
* priced ancestor's catch-all. Throws on negative leaf values.
*
* Precondition: ancestor coverage — if a unit is priced, all its ancestors must
* also be priced. Validated by validateAncestorCoverage() called from calcPrice.
*/
export function computeLeafValues(pricedUnitIds: Set<string>, usage: Record<string, unknown>, family: UnitFamily): Record<string, number> {
const result: Record<string, number> = {}

for (const unitId of pricedUnitIds) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const unit = family.units[unitId]!
const targetDepth = Object.keys(unit.dimensions).length

let leafValue = 0
for (const otherId of pricedUnitIds) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const other = family.units[otherId]!
if (!isDescendantOrSelf(unit, other)) continue
const depthDiff = Object.keys(other.dimensions).length - targetDepth
const coefficient = (-1) ** depthDiff
leafValue += coefficient * getUsageValue(usage, other.usageKey)
}

if (leafValue < 0) {
const involved = [...pricedUnitIds]
.filter((oid) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const o = family.units[oid]!
return isDescendantOrSelf(unit, o) && getUsageValue(usage, o.usageKey) !== 0
})
.map((oid) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const o = family.units[oid]!
return `${o.usageKey}=${String(getUsageValue(usage, o.usageKey))}`
})
throw new Error(`Negative leaf value (${String(leafValue)}) for ${unitId}: inconsistent usage values: ${involved.join(', ')}`)
}

result[unitId] = leafValue
}

return result
}
Loading
Loading