Skip to content

Commit ea676b0

Browse files
authored
Merge pull request #4 from MukundaKatta/codex/tokenwise-pricing-and-budgeting
feat: add versioned pricing and budget tracking
2 parents 21f81a7 + fb9744a commit ea676b0

File tree

7 files changed

+204
-36
lines changed

7 files changed

+204
-36
lines changed

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,22 @@ tracker.track(
103103
print(f"Total spend: ${tracker.total_cost():.6f}")
104104
```
105105

106+
### Multi-step Budget Breakdown
107+
108+
```python
109+
from tokenwise import BudgetTracker
110+
111+
tracker = BudgetTracker()
112+
tracker.add_step("draft", request="Write a landing page headline", response="Fast AI workflows for teams.")
113+
tracker.add_step("review", request="Critique the headline", response="Shorten the second clause.")
114+
115+
report = tracker.get_report(warning_threshold_usd=0.01)
116+
print(report.total_cost)
117+
print(report.pricing_version)
118+
for step in report.steps:
119+
print(step.name, step.total_tokens, step.total_cost)
120+
```
121+
106122
### CLI
107123

108124
```bash
@@ -139,6 +155,18 @@ summary = batch.batch_summary(unique)
139155
print(f"Saved {summary['total_tokens_saved']} tokens across {summary['prompt_count']} prompts")
140156
```
141157

158+
## Pricing Data
159+
160+
Model pricing now lives in a versioned package data file at `src/tokenwise/data/model_pricing.v1.json`.
161+
162+
That gives TokenWise a safer update workflow:
163+
164+
- pricing changes are separated from estimator logic
165+
- the catalog carries an explicit version
166+
- historical reports can point back to the pricing version used at the time
167+
168+
To update pricing, edit the JSON catalog, keep the schema consistent, and run the test suite before publishing.
169+
142170
## Pricing Table
143171

144172
| Model | Input (per 1K tokens) | Output (per 1K tokens) |

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ Issues = "https://github.com/MukundaKatta/TokenWise/issues"
4949
[tool.setuptools.packages.find]
5050
where = ["src"]
5151

52+
[tool.setuptools.package-data]
53+
tokenwise = ["data/*.json"]
54+
5255
[tool.pytest.ini_options]
5356
testpaths = ["tests"]
5457
pythonpath = ["src"]

src/tokenwise/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,21 @@
44

55
from tokenwise.core import (
66
BatchOptimizer,
7+
BudgetTracker,
78
CostEstimator,
89
TokenCounter,
910
TokenOptimizer,
1011
UsageTracker,
1112
)
12-
from tokenwise.config import TokenWiseConfig
13+
from tokenwise.config import PRICING_VERSION, TokenWiseConfig
1314

1415
__all__ = [
1516
"TokenCounter",
1617
"TokenOptimizer",
1718
"CostEstimator",
1819
"UsageTracker",
20+
"BudgetTracker",
1921
"BatchOptimizer",
2022
"TokenWiseConfig",
23+
"PRICING_VERSION",
2124
]

src/tokenwise/config.py

Lines changed: 25 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,27 @@
22

33
from __future__ import annotations
44

5+
import json
56
import os
7+
from functools import lru_cache
8+
from importlib.resources import files
69
from typing import Optional
710

811
from pydantic import BaseModel, Field
912

1013

11-
# Per-token pricing in USD (per 1K tokens)
14+
@lru_cache(maxsize=1)
15+
def load_pricing_catalog() -> dict:
16+
"""Load the versioned pricing catalog from package data."""
17+
catalog_path = files("tokenwise").joinpath("data/model_pricing.v1.json")
18+
return json.loads(catalog_path.read_text(encoding="utf-8"))
19+
20+
21+
PRICING_CATALOG = load_pricing_catalog()
22+
PRICING_VERSION = PRICING_CATALOG["version"]
1223
MODEL_PRICING: dict[str, dict[str, float]] = {
13-
"gpt-4": {"input": 0.03, "output": 0.06},
14-
"gpt-4-turbo": {"input": 0.01, "output": 0.03},
15-
"gpt-4o": {"input": 0.005, "output": 0.015},
16-
"gpt-3.5-turbo": {"input": 0.0005, "output": 0.0015},
17-
"claude-3-opus": {"input": 0.015, "output": 0.075},
18-
"claude-3-sonnet": {"input": 0.003, "output": 0.015},
19-
"claude-3-haiku": {"input": 0.00025, "output": 0.00125},
20-
"claude-3.5-sonnet": {"input": 0.003, "output": 0.015},
21-
"claude-4-opus": {"input": 0.015, "output": 0.075},
22-
"claude-4-sonnet": {"input": 0.003, "output": 0.015},
23-
"gemini-1.5-pro": {"input": 0.00125, "output": 0.005},
24-
"gemini-1.5-flash": {"input": 0.000075, "output": 0.0003},
25-
"llama-3-70b": {"input": 0.00059, "output": 0.00079},
26-
"llama-3-8b": {"input": 0.00005, "output": 0.00008},
27-
"mistral-large": {"input": 0.004, "output": 0.012},
28-
"mistral-small": {"input": 0.001, "output": 0.003},
24+
model: {"input": details["input"], "output": details["output"]}
25+
for model, details in PRICING_CATALOG["models"].items()
2926
}
3027

3128
# Characters-per-token ratio heuristics by model family
@@ -38,24 +35,9 @@
3835
"default": 3.7,
3936
}
4037

41-
# Default context window sizes
4238
MODEL_CONTEXT_WINDOWS: dict[str, int] = {
43-
"gpt-4": 8192,
44-
"gpt-4-turbo": 128000,
45-
"gpt-4o": 128000,
46-
"gpt-3.5-turbo": 16385,
47-
"claude-3-opus": 200000,
48-
"claude-3-sonnet": 200000,
49-
"claude-3-haiku": 200000,
50-
"claude-3.5-sonnet": 200000,
51-
"claude-4-opus": 200000,
52-
"claude-4-sonnet": 200000,
53-
"gemini-1.5-pro": 1000000,
54-
"gemini-1.5-flash": 1000000,
55-
"llama-3-70b": 8192,
56-
"llama-3-8b": 8192,
57-
"mistral-large": 32000,
58-
"mistral-small": 32000,
39+
model: details["context_window"]
40+
for model, details in PRICING_CATALOG["models"].items()
5941
}
6042

6143
# Default budget settings
@@ -84,6 +66,7 @@ class TokenWiseConfig(BaseModel):
8466
monthly_budget_usd: float = Field(default=DEFAULT_BUDGET["monthly_limit_usd"])
8567
alert_threshold_pct: int = Field(default=DEFAULT_BUDGET["alert_threshold_pct"])
8668
custom_pricing: Optional[dict[str, dict[str, float]]] = None
69+
pricing_version: str = Field(default=PRICING_VERSION)
8770

8871
def get_pricing(self, model: str) -> dict[str, float]:
8972
"""Return pricing dict for a model, checking custom overrides first."""
@@ -101,3 +84,11 @@ def get_tokenizer_ratio(self, model: str) -> float:
10184
if family in model.lower():
10285
return ratio
10386
return TOKENIZER_RATIOS["default"]
87+
88+
def get_context_window(self, model: str) -> int:
89+
"""Return the context window for a model."""
90+
if model in MODEL_CONTEXT_WINDOWS:
91+
return MODEL_CONTEXT_WINDOWS[model]
92+
raise ValueError(
93+
f"Unknown model '{model}'. Available: {', '.join(MODEL_CONTEXT_WINDOWS.keys())}"
94+
)

src/tokenwise/core.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from tokenwise.config import (
1212
MODEL_CONTEXT_WINDOWS,
1313
MODEL_PRICING,
14+
PRICING_VERSION,
1415
TokenWiseConfig,
1516
)
1617
from tokenwise.utils import (
@@ -34,6 +35,34 @@ class UsageRecord(BaseModel):
3435
total_tokens: int
3536
model: str
3637
estimated_cost: float
38+
pricing_version: str
39+
40+
41+
class BudgetStep(BaseModel):
42+
"""One step in a multi-step budget workflow."""
43+
44+
name: str
45+
model: str
46+
request_tokens: int
47+
response_tokens: int
48+
total_tokens: int
49+
input_cost: float
50+
output_cost: float
51+
total_cost: float
52+
metadata: dict[str, Any] = Field(default_factory=dict)
53+
54+
55+
class BudgetReport(BaseModel):
56+
"""Aggregate report for a multi-step budget workflow."""
57+
58+
pricing_version: str
59+
total_steps: int
60+
total_tokens: int
61+
total_cost: float
62+
warning_threshold_usd: float | None = None
63+
warning_triggered: bool = False
64+
by_model: dict[str, dict[str, float]]
65+
steps: list[BudgetStep]
3766

3867

3968
class BudgetAlert(BaseModel):
@@ -253,6 +282,7 @@ def track(self, request: str, response: str, model: Optional[str] = None) -> Usa
253282
total_tokens=req_tokens + res_tokens,
254283
model=model,
255284
estimated_cost=round(input_cost + output_cost, 8),
285+
pricing_version=self.config.pricing_version,
256286
)
257287
self._records.append(record)
258288
return record
@@ -300,6 +330,7 @@ def get_report(self) -> dict[str, Any]:
300330
"total_requests": len(self._records),
301331
"total_tokens": self.total_tokens(),
302332
"estimated_total_cost": self.total_cost(),
333+
"pricing_version": self.config.pricing_version,
303334
"by_model": by_model,
304335
}
305336

@@ -368,3 +399,70 @@ def deduplicate_prompts(self, prompts: list[str]) -> list[str]:
368399
seen.add(normalized)
369400
unique.append(p)
370401
return unique
402+
403+
404+
class BudgetTracker:
405+
"""Track token and cost breakdowns across multi-step tasks."""
406+
407+
def __init__(self, config: Optional[TokenWiseConfig] = None) -> None:
408+
self.config = config or TokenWiseConfig()
409+
self._counter = TokenCounter(self.config)
410+
self._estimator = CostEstimator(self.config)
411+
self._steps: list[BudgetStep] = []
412+
413+
def add_step(
414+
self,
415+
name: str,
416+
request: str,
417+
response: str = "",
418+
model: Optional[str] = None,
419+
metadata: Optional[dict[str, Any]] = None,
420+
) -> BudgetStep:
421+
"""Add one step to the budget report."""
422+
model = model or self.config.default_model
423+
request_tokens = self._counter.count(request, model)
424+
response_tokens = self._counter.count(response, model) if response else 0
425+
input_cost = self._estimator.estimate(request_tokens, model, "input")
426+
output_cost = self._estimator.estimate(response_tokens, model, "output")
427+
step = BudgetStep(
428+
name=name,
429+
model=model,
430+
request_tokens=request_tokens,
431+
response_tokens=response_tokens,
432+
total_tokens=request_tokens + response_tokens,
433+
input_cost=input_cost,
434+
output_cost=output_cost,
435+
total_cost=round(input_cost + output_cost, 8),
436+
metadata=metadata or {},
437+
)
438+
self._steps.append(step)
439+
return step
440+
441+
def get_report(self, warning_threshold_usd: float | None = None) -> BudgetReport:
442+
"""Return the full workflow budget report."""
443+
by_model: dict[str, dict[str, float]] = {}
444+
for step in self._steps:
445+
if step.model not in by_model:
446+
by_model[step.model] = {"steps": 0.0, "tokens": 0.0, "cost": 0.0}
447+
by_model[step.model]["steps"] += 1
448+
by_model[step.model]["tokens"] += step.total_tokens
449+
by_model[step.model]["cost"] += step.total_cost
450+
451+
total_cost = round(sum(step.total_cost for step in self._steps), 8)
452+
total_tokens = sum(step.total_tokens for step in self._steps)
453+
return BudgetReport(
454+
pricing_version=self.config.pricing_version,
455+
total_steps=len(self._steps),
456+
total_tokens=total_tokens,
457+
total_cost=total_cost,
458+
warning_threshold_usd=warning_threshold_usd,
459+
warning_triggered=(
460+
warning_threshold_usd is not None and total_cost >= warning_threshold_usd
461+
),
462+
by_model=by_model,
463+
steps=list(self._steps),
464+
)
465+
466+
def reset(self) -> None:
467+
"""Clear tracked steps."""
468+
self._steps.clear()
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"version": "2026-04-20",
3+
"models": {
4+
"gpt-4": {"input": 0.03, "output": 0.06, "context_window": 8192},
5+
"gpt-4-turbo": {"input": 0.01, "output": 0.03, "context_window": 128000},
6+
"gpt-4o": {"input": 0.005, "output": 0.015, "context_window": 128000},
7+
"gpt-3.5-turbo": {"input": 0.0005, "output": 0.0015, "context_window": 16385},
8+
"claude-3-opus": {"input": 0.015, "output": 0.075, "context_window": 200000},
9+
"claude-3-sonnet": {"input": 0.003, "output": 0.015, "context_window": 200000},
10+
"claude-3-haiku": {"input": 0.00025, "output": 0.00125, "context_window": 200000},
11+
"claude-3.5-sonnet": {"input": 0.003, "output": 0.015, "context_window": 200000},
12+
"claude-4-opus": {"input": 0.015, "output": 0.075, "context_window": 200000},
13+
"claude-4-sonnet": {"input": 0.003, "output": 0.015, "context_window": 200000},
14+
"gemini-1.5-pro": {"input": 0.00125, "output": 0.005, "context_window": 1000000},
15+
"gemini-1.5-flash": {"input": 0.000075, "output": 0.0003, "context_window": 1000000},
16+
"llama-3-70b": {"input": 0.00059, "output": 0.00079, "context_window": 8192},
17+
"llama-3-8b": {"input": 0.00005, "output": 0.00008, "context_window": 8192},
18+
"mistral-large": {"input": 0.004, "output": 0.012, "context_window": 32000},
19+
"mistral-small": {"input": 0.001, "output": 0.003, "context_window": 32000}
20+
}
21+
}

tests/test_core.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66

77
from tokenwise.core import (
88
BatchOptimizer,
9+
BudgetTracker,
910
CostEstimator,
1011
TokenCounter,
1112
TokenOptimizer,
1213
UsageTracker,
1314
)
14-
from tokenwise.config import TokenWiseConfig
15+
from tokenwise.config import PRICING_VERSION, TokenWiseConfig, load_pricing_catalog
1516

1617

1718
class TestTokenCounter:
@@ -95,6 +96,9 @@ def test_compare_models_sorted_by_cost(self) -> None:
9596
costs = [info["cost"] for info in result.values()]
9697
assert costs == sorted(costs)
9798

99+
def test_pricing_version_exposed_from_catalog(self) -> None:
100+
assert PRICING_VERSION == load_pricing_catalog()["version"]
101+
98102

99103
class TestUsageTracker:
100104
"""Tests for usage tracking."""
@@ -108,6 +112,7 @@ def test_track_single_request(self) -> None:
108112
assert record.request_tokens > 0
109113
assert record.response_tokens > 0
110114
assert record.total_tokens == record.request_tokens + record.response_tokens
115+
assert record.pricing_version == PRICING_VERSION
111116

112117
def test_report_aggregates_correctly(self) -> None:
113118
tracker = UsageTracker()
@@ -118,6 +123,7 @@ def test_report_aggregates_correctly(self) -> None:
118123
assert report["total_requests"] == 2
119124
assert report["total_tokens"] > 0
120125
assert report["estimated_total_cost"] > 0
126+
assert report["pricing_version"] == PRICING_VERSION
121127

122128
def test_reset_clears_log(self) -> None:
123129
tracker = UsageTracker()
@@ -197,3 +203,21 @@ def test_deduplicate(self) -> None:
197203
prompts = ["Hello", "hello", "World", "Hello"]
198204
unique = batch.deduplicate_prompts(prompts)
199205
assert len(unique) == 2
206+
207+
208+
class TestBudgetTracker:
209+
"""Tests for multi-step budget reporting."""
210+
211+
def test_budget_report_breaks_costs_down_by_step(self) -> None:
212+
tracker = BudgetTracker()
213+
tracker.add_step("draft", request="Write a summary", response="Here is a draft", model="gpt-4o")
214+
tracker.add_step("review", request="Critique the draft", response="Needs more detail", model="gpt-4o")
215+
216+
report = tracker.get_report(warning_threshold_usd=0.00000001)
217+
218+
assert report.total_steps == 2
219+
assert report.total_tokens > 0
220+
assert report.total_cost > 0
221+
assert report.warning_triggered is True
222+
assert report.pricing_version == PRICING_VERSION
223+
assert len(report.steps) == 2

0 commit comments

Comments
 (0)