项目文件夹

文件
2025-10-15 17:08:31 -07:00

523 行
14 KiB
Markdown

# Cost Estimation - FINAL Implementation Checklist
## ⚠️ KEY UPDATES
- **Do NOT bundle** pricing data with package
- **Fetch on demand** from llm-prices.com
- **Cache** in `user_dir() / "historical-v1.json"`
- **24-hour** cache refresh
- **Both sync and async** support
## Phase 1: Core Functionality (2 days)
### 1.1 Create `llm/costs.py` - Basic Structure
- [ ] File setup and imports
```python
import httpx
import json
import time
from pathlib import Path
from typing import Optional
from datetime import datetime
from dataclasses import dataclass
import llm
```
- [ ] Define constants
```python
PRICING_URL = "https://www.llm-prices.com/historical-v1.json"
CACHE_FILENAME = "historical-v1.json"
CACHE_MAX_AGE_HOURS = 24
```
### 1.2 Dataclasses
- [ ] `PriceInfo` dataclass
```python
@dataclass
class PriceInfo:
id: str
vendor: str
name: str
input_price: float
output_price: float
cached_input_price: Optional[float] = None
from_date: Optional[datetime] = None
to_date: Optional[datetime] = None
```
- [ ] `Cost` dataclass
```python
@dataclass
class Cost:
input_cost: float
output_cost: float
cached_cost: float
total_cost: float
currency: str = "USD"
model_id: str = ""
price_info: Optional[PriceInfo] = None
```
### 1.3 CostEstimator (Sync)
- [ ] Class setup with singleton pattern
```python
class CostEstimator:
_instance = None
_pricing_data = None
_last_loaded = None
```
- [ ] `_get_cache_path()` method
```python
def _get_cache_path(self) -> Path:
return llm.user_dir() / CACHE_FILENAME
```
- [ ] `_is_cache_fresh()` method
```python
def _is_cache_fresh(self) -> bool:
cache_path = self._get_cache_path()
if not cache_path.exists():
return False
age = time.time() - cache_path.stat().st_mtime
return age < (CACHE_MAX_AGE_HOURS * 3600)
```
- [ ] `_fetch_pricing_data()` method
```python
def _fetch_pricing_data(self) -> dict:
response = httpx.get(PRICING_URL, timeout=10.0, follow_redirects=True)
response.raise_for_status()
return response.json()
```
- [ ] `_load_from_cache()` method
```python
def _load_from_cache(self) -> dict:
return json.loads(self._get_cache_path().read_text())
```
- [ ] `_save_to_cache()` method
```python
def _save_to_cache(self, data: dict):
cache_path = self._get_cache_path()
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(json.dumps(data))
```
- [ ] `_ensure_pricing_data()` method (main logic)
```python
def _ensure_pricing_data(self, force_refresh: bool = False):
if force_refresh or not self._is_cache_fresh():
try:
data = self._fetch_pricing_data()
self._save_to_cache(data)
self._pricing_data = data
except Exception as e:
if self._get_cache_path().exists():
self._pricing_data = self._load_from_cache()
else:
raise Exception(f"Failed to fetch pricing data: {e}")
else:
if self._pricing_data is None:
self._pricing_data = self._load_from_cache()
```
- [ ] `_normalize_model_id()` helper
```python
def _normalize_model_id(self, model_id: str) -> str:
# Strip date suffixes, etc.
```
- [ ] `_find_price()` method (exact match)
```python
def _find_price(self, model_id: str, date: Optional[datetime] = None) -> Optional[PriceInfo]:
# Search in self._pricing_data["prices"]
```
- [ ] `_find_price_fuzzy()` method (fuzzy match)
```python
def _find_price_fuzzy(self, model_id: str) -> Optional[PriceInfo]:
# Try common variations
```
- [ ] `get_price()` public method
```python
def get_price(self, model_id: str, date: Optional[datetime] = None) -> Optional[PriceInfo]:
self._ensure_pricing_data()
price = self._find_price(model_id, date)
if not price:
price = self._find_price_fuzzy(model_id)
return price
```
- [ ] `calculate_cost()` main method
```python
def calculate_cost(
self,
model_id: str,
input_tokens: int,
output_tokens: int,
cached_tokens: Optional[int] = None,
date: Optional[datetime] = None
) -> Optional[Cost]:
price = self.get_price(model_id, date)
if not price:
return None
# Calculate costs per million tokens
input_cost = (input_tokens * price.input_price) / 1_000_000
output_cost = (output_tokens * price.output_price) / 1_000_000
cached_cost = 0.0
if cached_tokens and price.cached_input_price:
cached_cost = (cached_tokens * price.cached_input_price) / 1_000_000
return Cost(
input_cost=input_cost,
output_cost=output_cost,
cached_cost=cached_cost,
total_cost=input_cost + output_cost + cached_cost,
model_id=model_id,
price_info=price
)
```
- [ ] `get_default_estimator()` singleton function
```python
def get_default_estimator() -> CostEstimator:
if CostEstimator._instance is None:
CostEstimator._instance = CostEstimator()
return CostEstimator._instance
```
### 1.4 AsyncCostEstimator
- [ ] Copy structure from CostEstimator
- [ ] Make `_fetch_pricing_data()` async
```python
async def _fetch_pricing_data(self) -> dict:
async with httpx.AsyncClient() as client:
response = await client.get(PRICING_URL, timeout=10.0)
response.raise_for_status()
return response.json()
```
- [ ] Make `_ensure_pricing_data()` async
```python
async def _ensure_pricing_data(self, force_refresh: bool = False):
# Same logic but with await
```
- [ ] Make `calculate_cost()` async
```python
async def calculate_cost(...) -> Optional[Cost]:
await self._ensure_pricing_data()
# Rest is same as sync version
```
- [ ] `get_async_estimator()` function
```python
async def get_async_estimator() -> AsyncCostEstimator:
if AsyncCostEstimator._instance is None:
AsyncCostEstimator._instance = AsyncCostEstimator()
await AsyncCostEstimator._instance._ensure_pricing_data()
return AsyncCostEstimator._instance
```
### 1.5 Unit Tests (`tests/test_costs.py`)
- [ ] Test fixtures
```python
@pytest.fixture
def mock_pricing_data():
return {"prices": [...]}
@pytest.fixture
def temp_user_dir(tmp_path, monkeypatch):
monkeypatch.setattr(llm, "user_dir", lambda: tmp_path)
return tmp_path
```
- [ ] `test_fetch_pricing_data()` - with mocked httpx
- [ ] `test_cache_saves_to_correct_location()`
- [ ] `test_cache_age_detection_fresh()`
- [ ] `test_cache_age_detection_stale()`
- [ ] `test_load_from_cache()`
- [ ] `test_fallback_to_stale_cache_on_network_error()`
- [ ] `test_error_when_no_cache_and_network_fails()`
- [ ] `test_exact_model_match()`
- [ ] `test_fuzzy_model_match_gpt4()`
- [ ] `test_fuzzy_model_match_claude()`
- [ ] `test_model_not_found_returns_none()`
- [ ] `test_calculate_cost_basic()`
- [ ] `test_calculate_cost_with_cached_tokens()`
- [ ] `test_calculate_cost_no_pricing_returns_none()`
- [ ] `test_calculate_cost_zero_tokens()`
- [ ] `test_historical_pricing_selection()`
- [ ] `test_async_estimator()` - async version tests
## Phase 2: Integration (0.5 day)
### 2.1 Response.cost() Method
- [ ] Add to `Response` class in `llm/models.py`
```python
def cost(
self,
estimator: Optional["CostEstimator"] = None
) -> Optional["Cost"]:
"""Calculate cost for this response."""
if estimator is None:
from .costs import get_default_estimator
estimator = get_default_estimator()
return estimator.calculate_cost(
model_id=self.resolved_model or self.model.model_id,
input_tokens=self.input_tokens or 0,
output_tokens=self.output_tokens or 0,
cached_tokens=self._get_cached_tokens(),
date=self.datetime_utc()
)
```
- [ ] Add `_get_cached_tokens()` helper
```python
def _get_cached_tokens(self) -> Optional[int]:
if self.token_details:
return (
self.token_details.get('cache_read_input_tokens') or
self.token_details.get('cached_tokens') or
None
)
return None
```
### 2.2 AsyncResponse.cost() Method
- [ ] Add async version to `AsyncResponse` class
```python
async def cost(
self,
estimator: Optional["AsyncCostEstimator"] = None
) -> Optional["Cost"]:
"""Calculate cost for this response (async)."""
if estimator is None:
from .costs import get_async_estimator
estimator = await get_async_estimator()
return await estimator.calculate_cost(
model_id=self.resolved_model or self.model.model_id,
input_tokens=self.input_tokens or 0,
output_tokens=self.output_tokens or 0,
cached_tokens=self._get_cached_tokens(),
date=self.datetime_utc()
)
```
### 2.3 Update Exports
- [ ] Add to `llm/__init__.py`
```python
from .costs import CostEstimator, AsyncCostEstimator, Cost, PriceInfo
```
- [ ] Add to `__all__` list
```python
__all__ = [
# ... existing ...
"Cost",
"CostEstimator",
"AsyncCostEstimator",
"PriceInfo",
]
```
### 2.4 Integration Tests
- [ ] `test_response_cost_sync()` - Test Response.cost()
- [ ] `test_response_cost_async()` - Test AsyncResponse.cost()
- [ ] `test_response_cost_with_custom_estimator()`
- [ ] `test_response_cost_no_tokens_returns_none()`
- [ ] `test_response_cost_unknown_model_returns_none()`
- [ ] `test_response_cached_tokens_anthropic_format()`
## Phase 3: CLI Enhancement (0.5 day)
### 3.1 Enhance token_usage_string()
- [ ] Update signature in `llm/utils.py`
```python
def token_usage_string(
input_tokens,
output_tokens,
token_details,
model_id: Optional[str] = None,
datetime_utc: Optional[datetime] = None,
show_cost: bool = True
) -> str:
```
- [ ] Add cost calculation logic
```python
if show_cost and model_id:
try:
from .costs import get_default_estimator
estimator = get_default_estimator()
cached_tokens = None
if token_details:
cached_tokens = (
token_details.get('cache_read_input_tokens') or
token_details.get('cached_tokens')
)
cost = estimator.calculate_cost(
model_id=model_id,
input_tokens=input_tokens or 0,
output_tokens=output_tokens or 0,
cached_tokens=cached_tokens,
date=datetime_utc
)
if cost:
cost_str = f"${cost.total_cost:.6f}"
if cost.cached_cost > 0:
cost_str += f" (${cost.input_cost:.6f} input, ${cost.output_cost:.6f} output, ${cost.cached_cost:.6f} cached)"
else:
cost_str += f" (${cost.input_cost:.6f} input, ${cost.output_cost:.6f} output)"
bits.append(f"Cost: {cost_str}")
except Exception:
pass # Silently skip on error
```
### 3.2 Update llm prompt command
- [ ] Find usage display (~line 901 in `llm/cli.py`)
- [ ] Update call to pass model_id and datetime
```python
if usage:
for response_object in responses:
usage_str = token_usage_string(
response_object.input_tokens,
response_object.output_tokens,
response_object.token_details,
model_id=response_object.resolved_model or response_object.model.model_id,
datetime_utc=response_object.datetime_utc()
)
click.echo(
click.style(
"Token usage: {}".format(usage_str),
fg="yellow",
bold=True,
),
err=True,
)
```
### 3.3 Update llm logs command
- [ ] Find usage display (~line 2182 in `llm/cli.py`)
- [ ] Update call to pass model_id and datetime
```python
if usage:
from datetime import datetime as dt
token_usage = token_usage_string(
row["input_tokens"],
row["output_tokens"],
json.loads(row["token_details"]) if row["token_details"] else None,
model_id=row.get("resolved_model") or row["model"],
datetime_utc=dt.fromisoformat(row["datetime_utc"]) if row.get("datetime_utc") else None
)
if token_usage:
click.echo("## Token usage\n\n{}\n".format(token_usage))
```
### 3.4 CLI Tests
- [ ] `test_prompt_with_usage_shows_cost()`
- [ ] `test_prompt_with_usage_unknown_model_no_cost()`
- [ ] `test_prompt_with_usage_network_error_no_cost()`
- [ ] `test_logs_with_usage_shows_cost()`
- [ ] `test_usage_backward_compatibility()`
## Phase 4: Documentation & Polish (1 day)
### 4.1 Docstrings
- [ ] Complete docstrings for all public methods
- [ ] Type hints everywhere
- [ ] Examples in docstrings
### 4.2 Error Handling Review
- [ ] Network timeout handling
- [ ] Graceful degradation
- [ ] Logging (debug level only)
- [ ] Never break existing functionality
### 4.3 Documentation
- [ ] Update README.md with example
- [ ] Create docs/costs.md
- [ ] Update docs/usage.md
- [ ] Document cache location
- [ ] Document 24-hour refresh
### 4.4 Testing
- [ ] Run full test suite
- [ ] Manual testing all scenarios
- [ ] Coverage check (>90%)
- [ ] Performance testing
## Testing Commands
```bash
# Unit tests
pytest tests/test_costs.py -v
# Integration tests
pytest tests/test_costs.py tests/test_cli.py -v
# Coverage
pytest --cov=llm.costs --cov=llm.utils --cov-report=html
# Manual tests
# First use (no cache)
rm ~/.local/share/io.datasette.llm/historical-v1.json
llm "Test" -m gpt-4 -u
# Subsequent use (from cache)
llm "Test" -m gpt-4 -u
# Force stale cache
touch -t 202401010000 ~/.local/share/io.datasette.llm/historical-v1.json
llm "Test" -m gpt-4 -u
# Test offline (should use stale cache or skip cost)
# Disconnect network, then:
llm "Test" -m gpt-4 -u
```
## Success Checklist
- [ ] Pricing data fetches on first use
- [ ] Cache saves to correct location (user_dir/historical-v1.json)
- [ ] Cache refresh works after 24 hours
- [ ] Network errors handled gracefully
- [ ] Sync Response.cost() works
- [ ] Async AsyncResponse.cost() works
- [ ] Cost appears in -u output
- [ ] Unknown models don't crash
- [ ] Tests pass with >90% coverage
- [ ] Documentation complete
- [ ] No breaking changes
---
**Estimated time: 4 days total**