Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ node_modules/
.claude/
/.cursor/
/.devcontainer/
docs/.model-prices-cache.json
6 changes: 6 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,9 @@ repos:
language: system
types: [python]
pass_filenames: false
- id: update-model-prices
name: Update model prices
entry: uv run python docs/.hooks/update_model_prices.py
language: system
files: ^docs/models/popular-models\.md$
pass_filenames: false
199 changes: 199 additions & 0 deletions docs/.hooks/update_model_prices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Pre-commit hook to update model pricing in popular-models.md from genai-prices"""

from __future__ import annotations

import json
import os
import re
import sys
from pathlib import Path
from urllib.request import urlopen

from typing_extensions import NotRequired, TypedDict

# Skip in CI
if os.environ.get('CI'):
sys.exit(0)

PRICES_URL = 'https://raw.githubusercontent.com/pydantic/genai-prices/refs/heads/main/prices/data.json'
DOCS_DIR = Path(__file__).parent.parent
CONFIG_FILE = DOCS_DIR / 'models/popular-models-config.json'
CACHE_FILE = DOCS_DIR / '.model-prices-cache.json'
TARGET_FILE = DOCS_DIR / 'models/popular-models.md'


# Config types
class ModelMapping(TypedDict):
provider: str
model_id: str
default_price: NotRequired[str]


class Config(TypedDict):
models: dict[str, ModelMapping]


# Tiered pricing types
class PriceTier(TypedDict):
start: int
price: float


class TieredPrice(TypedDict):
base: float
tiers: list[PriceTier]


Price = float | int | TieredPrice


# genai-prices API types
class ModelPrices(TypedDict, total=False):
input_mtok: Price
output_mtok: Price


class GenaIPricesModel(TypedDict):
id: str
prices: NotRequired[ModelPrices | list[ModelPrices]]


class GenaIPricesProvider(TypedDict):
id: str
models: list[GenaIPricesModel]


# Cached prices type
class CachedPrice(TypedDict):
input_mtok: Price
output_mtok: Price


def load_config() -> Config:
"""Load model mapping config"""
return json.loads(CONFIG_FILE.read_text())


def fetch_prices() -> dict[str, CachedPrice]:
"""Fetch prices from genai-prices and cache locally"""
with urlopen(PRICES_URL) as resp:
data: list[GenaIPricesProvider] = json.loads(resp.read())

prices: dict[str, CachedPrice] = {}
for provider in data:
provider_id = provider['id']
for model in provider.get('models', []):
model_id = model['id']
raw_prices = model.get('prices')
if raw_prices is None:
continue

price_data: ModelPrices
if isinstance(raw_prices, list):
price_data = raw_prices[0] if raw_prices else {}
else:
price_data = raw_prices

input_price = price_data.get('input_mtok')
output_price = price_data.get('output_mtok')

if input_price is not None and output_price is not None:
prices[f'{provider_id}:{model_id}'] = {
'input_mtok': input_price,
'output_mtok': output_price,
}

CACHE_FILE.write_text(json.dumps(prices, indent=2))
return prices


def format_price(input_p: Price, output_p: Price) -> str:
"""Format price, handling tiered pricing"""

def fmt(p: Price) -> str:
if isinstance(p, dict):
base = float(p['base'])
tiers = p.get('tiers')
tier_price = float(tiers[0]['price']) if tiers else base
if base != tier_price:
return f'${base:.2f}-{tier_price:.2f}'
return f'${base:.2f}'
return f'${float(p):.2f}'

return f'{fmt(input_p)} / {fmt(output_p)} per 1M tokens (in/out)'


def get_price_for_model(
doc_model_id: str, config: Config, prices: dict[str, CachedPrice]
) -> tuple[str | None, str | None]:
"""Get formatted price for a model, or None if not found"""
model_config = config['models'].get(doc_model_id)
if not model_config:
return None, f'Model {doc_model_id!r} not in config'

# Check for default price override
if 'default_price' in model_config:
return model_config['default_price'], None

# Lookup in fetched prices
provider = model_config['provider']
model_id = model_config['model_id']
key = f'{provider}:{model_id}'

price_data = prices.get(key)
if not price_data:
return None, f'Price not found for {key!r} (doc model: {doc_model_id!r})'

return format_price(price_data['input_mtok'], price_data['output_mtok']), None


def update_pricing(content: str, config: Config, prices: dict[str, CachedPrice]) -> tuple[str, list[str]]:
"""Update pricing lines in markdown content"""
lines = content.split('\n')
result: list[str] = []
current_model_id: str | None = None
errors: list[str] = []

for line in lines:
# Detect model ID line: ID: `provider:model`
if line.startswith('ID: `'):
match = re.search(r'ID: `([^`]+)`', line)
if match:
current_model_id = match.group(1)

# Update pricing line
if line.startswith('**Pricing:**') and current_model_id:
price, error = get_price_for_model(current_model_id, config, prices)
if price:
line = f'**Pricing:** {price}'
elif error:
errors.append(error)
current_model_id = None # Reset after processing

result.append(line)

return '\n'.join(result), errors


def main() -> int:
config = load_config()
prices = fetch_prices()
content = TARGET_FILE.read_text()
updated, errors = update_pricing(content, config, prices)

if errors:
print('ERROR: Missing prices (add default_price to config or update genai-prices):')
for error in errors:
print(f' - {error}')
return 1

if content != updated:
TARGET_FILE.write_text(updated)
print(f'Updated pricing in {TARGET_FILE}')

return 0


if __name__ == '__main__':
sys.exit(main())
75 changes: 75 additions & 0 deletions docs/extra/tweaks.css
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,61 @@ img.index-header {
text-align: center;
}

/* Model comparison rating badges */
.rating-badge {
display: inline-block;
width: 1.5em;
height: 1.5em;
line-height: 1.5em;
text-align: center;
border-radius: 4px;
font-weight: bold;
color: white;
}
.rating-5 { background-color: #FF007F; } /* Pydantic pink */
.rating-4 { background-color: #22c55e; } /* Green */
.rating-3 { background-color: #eab308; } /* Yellow */
.rating-2 { background-color: #f97316; } /* Orange */
.rating-1 { background-color: #ef4444; } /* Red */

/* Model card with two-column internal layout */
.model-card {
border: 1px solid var(--md-default-fg-color--lightest);
border-radius: 0.5rem;
padding: 1.5rem;
margin: 1.5rem 0;
}

.model-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-top: 1rem;
}

@media (max-width: 768px) {
.model-details {
grid-template-columns: 1fr;
}
}

/* Performance ratings list with aligned badges */
.perf-list {
list-style: disc;
padding-left: 1.5rem;
margin: 0.5rem 0;
}

.perf-list li {
display: flex;
align-items: center;
gap: 0.5rem;
}

.perf-list li span:first-child {
min-width: 5.5rem;
}

.md-search__input::-webkit-search-decoration,
.md-search__input::-webkit-search-cancel-button,
.md-search__input::-webkit-search-results-button,
Expand All @@ -79,3 +134,23 @@ img.index-header {
flex-direction: row;
gap: 10px;
}

/* Copyable model shorthands */
code.shorthand-copyable {
position: relative;
transition: all 0.2s ease;
}

code.shorthand-copyable:hover {
background-color: var(--md-accent-fg-color, #ff007f);
color: white;
padding: 0.05rem 0.1rem;
border-radius: 0.1rem;
}

code.shorthand-copyable.copied {
background-color: #22c55e;
color: white;
padding: 0.05rem 0.1rem;
border-radius: 0.1rem;
}
78 changes: 78 additions & 0 deletions docs/javascripts/shorthand-copy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Make model shorthands (e.g., 'anthropic:claude-opus-4-5') clickable and copyable
* This script finds inline code blocks on the popular-models page that contain
* model shorthand IDs and adds copy-on-click functionality.
*/

function initializeShorthandCopy() {
// Find all inline code elements
const codeElements = document.querySelectorAll('code:not([data-shorthand-processed])');

codeElements.forEach((code) => {
const text = code.textContent.trim();

// Pattern: matches model shorthands like 'anthropic:claude-opus-4-5' or 'gateway/anthropic:claude-opus-4-5'
// Remove surrounding quotes if present
const cleanText = text.replace(/^['"]|['"]$/g, '');

// Check if it looks like a model shorthand (contains : or gateway/)
if (
cleanText.includes(':') &&
(cleanText.startsWith('anthropic:') ||
cleanText.startsWith('azure:') ||
cleanText.startsWith('google-') ||
cleanText.startsWith('openai:') ||
cleanText.startsWith('grok:') ||
cleanText.startsWith('gateway/') ||
cleanText.startsWith('bedrock:') ||
cleanText.startsWith('openrouter:') ||
cleanText.startsWith('google-vertex:'))
) {
// Mark as processed to avoid re-processing
code.setAttribute('data-shorthand-processed', 'true');

// Add copyable class for styling
code.classList.add('shorthand-copyable');
code.style.cursor = 'pointer';

code.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();

// Copy to clipboard
try {
await navigator.clipboard.writeText(cleanText);

// Show feedback
const originalText = code.textContent;
code.textContent = '✓ Copied!';
code.classList.add('copied');

setTimeout(() => {
code.textContent = originalText;
code.classList.remove('copied');
}, 500);
} catch (err) {
console.error('Failed to copy:', err);
}
});
}
});
}

// Run when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeShorthandCopy);
} else {
initializeShorthandCopy();
}

// Also run on any dynamic content updates (for search results, etc.)
const observer = new MutationObserver(() => {
initializeShorthandCopy();
});

observer.observe(document.body, {
childList: true,
subtree: true,
});
Loading