-
Notifications
You must be signed in to change notification settings - Fork 25
feat: price cached input tokens in standalone cost calculation #356
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
958aae4
c691d49
e813f35
005ad20
c4b9dec
4c6f943
dddc6ea
f3095b2
3c52a04
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| """Add cache pricing columns to model_pricing. | ||
|
|
||
| Revision ID: c4d6e8f0a2b4 | ||
| Revises: f3b6d8a1c4e7 | ||
| Create Date: 2026-07-23 12:00:00.000000 | ||
|
|
||
| """ | ||
|
|
||
| from collections.abc import Sequence | ||
|
|
||
| import sqlalchemy as sa | ||
| from alembic import op | ||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision: str = "c4d6e8f0a2b4" | ||
| down_revision: str | Sequence[str] | None = "f3b6d8a1c4e7" | ||
| branch_labels: str | Sequence[str] | None = None | ||
| depends_on: str | Sequence[str] | None = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| """Upgrade schema.""" | ||
| # Nullable: providers/models without prompt caching leave these unset. | ||
| # The cost calculation in log_usage only prices cache tokens when a rate | ||
| # is configured, so existing rows are unaffected. | ||
| op.add_column( | ||
| "model_pricing", | ||
| sa.Column("cache_read_price_per_million", sa.Float(), nullable=True), | ||
| ) | ||
| op.add_column( | ||
| "model_pricing", | ||
| sa.Column("cache_write_price_per_million", sa.Float(), nullable=True), | ||
| ) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| """Downgrade schema.""" | ||
| op.drop_column("model_pricing", "cache_write_price_per_million") | ||
| op.drop_column("model_pricing", "cache_read_price_per_million") |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -81,11 +81,11 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from gateway.core.config import GatewayConfig | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from gateway.core.env import otari_env | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from gateway.core.usage import cache_read_tokens_of, cache_write_tokens_of | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from gateway.core.usage import cache_read_tokens_of, cache_tokens_in_prompt_of, cache_write_tokens_of | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from gateway.log_config import logger | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from gateway.metrics import record_abandoned_attempt, record_cost, record_tokens | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from gateway.model_labeling import relabel_model | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from gateway.models.entities import UsageLog | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from gateway.models.entities import ModelPricing, UsageLog | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from gateway.models.guardrails import GuardrailConfig | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from gateway.models.mcp import McpServerConfig | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from gateway.rate_limit import RateLimitInfo, check_rate_limit | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -957,6 +957,71 @@ async def prepare_gateway_tools( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # --------------------------------------------------------------------------- | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _compute_cost(pricing: ModelPricing, usage_data: CompletionUsage) -> float: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Compute standalone-mode cost from provider usage and model pricing. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Prices prompt, completion, and (when configured) cache tokens on a single | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| convention borrowed from the genai-prices dataset this project already uses: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| the input/prompt token count is the grand total that *includes* cache reads | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| and writes, and each physical token is charged once. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Providers report cache tokens two different ways, so usage is normalized | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| first (see :attr:`GatewayUsage.cache_tokens_in_prompt`): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * OpenAI / Gemini report cache reads as a subset already inside | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ``prompt_tokens`` (``cache_tokens_in_prompt`` is ``True``). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Anthropic reports ``input_tokens`` *exclusive* of cache reads/writes and | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| lists them as separate buckets (``cache_tokens_in_prompt`` is ``False``), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| so they are folded back into the total here. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Uncached input is then ``total - cache_read - cache_write``, priced at the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| input rate. Cache reads/writes are charged at their own rate when one is | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| configured; when a cache rate is absent, those tokens stay in the uncached | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| bucket and are billed at the full input rate rather than being dropped or | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| double counted. This mirrors ``ModelPrice.calc_price`` in genai-prices. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| A malformed provider payload (e.g. a cached count larger than | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ``prompt_tokens`` on the OpenAI shape) is clamped so the uncached remainder | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| can never go negative and produce a negative cost that would credit the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| user's budget. Cost is therefore always non-negative. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| prompt_tokens = usage_data.prompt_tokens or 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| completion_tokens = usage_data.completion_tokens or 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cache_read = cache_read_tokens_of(usage_data) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cache_write = cache_write_tokens_of(usage_data) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if cache_tokens_in_prompt_of(usage_data): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| total_input_tokens = prompt_tokens | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| total_input_tokens = prompt_tokens + cache_read + cache_write | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Defensive clamp: keep the cache buckets inside the input total so the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # uncached remainder stays >= 0 even if a provider/adapter reports an | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # inconsistent payload. For the Anthropic shape the total is built from | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # these counts, so this is a no-op there; it only bites a broken OpenAI-shape | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # payload where cached_tokens exceeds prompt_tokens. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cache_read = min(cache_read, total_input_tokens) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cache_write = min(cache_write, total_input_tokens - cache_read) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| read_rate = pricing.cache_read_price_per_million | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| write_rate = pricing.cache_write_price_per_million | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Only pull a cache bucket out of the uncached-input total when it has its | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # own price; otherwise it stays billed at the input rate. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| priced_cache_read = cache_read if read_rate is not None else 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| priced_cache_write = cache_write if write_rate is not None else 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| uncached_input_tokens = total_input_tokens - priced_cache_read - priced_cache_write | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+988
to
+1013
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No lower-bound clamp: on the |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cost = (uncached_input_tokens / 1_000_000) * pricing.input_price_per_million | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cost += (completion_tokens / 1_000_000) * pricing.output_price_per_million | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if read_rate is not None and cache_read > 0: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cost += (cache_read / 1_000_000) * read_rate | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if write_rate is not None and cache_write > 0: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cost += (cache_write / 1_000_000) * write_rate | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return cost | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+988
to
+1022
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Clamp malformed negative token counts too. The upper-bound clamps retain negative Proposed fix- prompt_tokens = usage_data.prompt_tokens or 0
- completion_tokens = usage_data.completion_tokens or 0
- cache_read = cache_read_tokens_of(usage_data)
- cache_write = cache_write_tokens_of(usage_data)
+ prompt_tokens = max(0, usage_data.prompt_tokens or 0)
+ completion_tokens = max(0, usage_data.completion_tokens or 0)
+ cache_read = max(0, cache_read_tokens_of(usage_data))
+ cache_write = max(0, cache_write_tokens_of(usage_data))📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _elapsed_ms(started_at: float | None) -> int | None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Milliseconds elapsed since a monotonic ``started_at`` reading. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -1041,9 +1106,7 @@ async def log_usage( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pricing = await find_model_pricing(db, provider, model, as_of=usage_log.timestamp) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if pricing: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cost = (usage_data.prompt_tokens / 1_000_000) * pricing.input_price_per_million + ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| usage_data.completion_tokens / 1_000_000 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) * pricing.output_price_per_million | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cost = _compute_cost(pricing, usage_data) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| usage_log.cost = cost | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| record_cost(str(provider or ""), model, cost) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Use a level-three heading here.
#### Cache token pricingskips from## Pricing; change it to###to satisfy Markdown heading hierarchy.🧰 Tools
🪛 markdownlint-cli2 (0.23.0)
[warning] 272-272: Heading levels should only increment by one level at a time
Expected: h3; Actual: h4
(MD001, heading-increment)
🤖 Prompt for AI Agents
Source: Linters/SAST tools