Skip to content
Merged
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
39 changes: 39 additions & 0 deletions alembic/versions/c4d6e8f0a2b4_add_cache_pricing_columns.py
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")
24 changes: 24 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,30 @@ Config pricing sets initial values. Pricing set via the `/v1/pricing` API takes

A pricing entry whose provider is not listed in the `providers` section is skipped at startup with a warning, not treated as a fatal error: the provider may still be reachable through environment credentials (any-llm reads keys like `OPENAI_API_KEY`), so a pricing/provider mismatch should not abort the gateway. To have such an entry take effect, add the provider to the `providers` section.

#### Cache token pricing

Copy link
Copy Markdown

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 pricing skips 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/configuration.md` at line 272, Update the “Cache token pricing” heading
in the configuration documentation from level four to level three so it
correctly nests beneath the existing “Pricing” level-two heading.

Source: Linters/SAST tools


Providers that support prompt caching (OpenAI, Gemini, Anthropic) report cached-token counts that are captured in `cache_read_tokens` and `cache_write_tokens` on the usage log. Without a configured cache rate these tokens are still billed, at `input_price_per_million` (they are ordinary input tokens), just not at a discounted cache rate. To bill them at the provider's cache price instead, add the optional `cache_read_price_per_million` and `cache_write_price_per_million` rates (USD per million tokens):

```yaml
pricing:
openai:gpt-4o:
input_price_per_million: 2.50
output_price_per_million: 10.00
cache_read_price_per_million: 1.25 # discounted rate for cached input
anthropic:claude-sonnet-4-6:
input_price_per_million: 3.00
output_price_per_million: 15.00
cache_read_price_per_million: 0.30 # cache-read rate
cache_write_price_per_million: 3.75 # cache-creation (write) rate
```

Every physical input token is charged once. The input/prompt count is treated as the grand total that *includes* cache reads and writes; the uncached remainder is billed at `input_price_per_million`, and cache tokens are re-priced at their own rate when one is configured. Providers report cache counts two ways, and the gateway normalizes both onto that single model:

- **OpenAI / Gemini**: `cache_read_tokens` is already a subset of `prompt_tokens`, so setting `cache_read_price_per_million` discounts the cached portion (re-priced at the cache rate instead of the full `input_price_per_million`) rather than double-counting it. `cache_write_tokens` is always 0 for these providers.
- **Anthropic**: `cache_read_tokens` and `cache_write_tokens` are reported separately from `prompt_tokens`, so they are added on top and billed at `cache_read_price_per_million` / `cache_write_price_per_million`. This holds on every turn, including warm-cache reads that create no new cache (`cache_write_tokens` is 0).

When a cache rate is left unset (null), those cache tokens are not dropped or billed at $0: they remain part of the input total and are billed at `input_price_per_million`, since they are still real input tokens. Set the cache rate to bill them at the provider's discounted cache price. The same fields are available on the `/v1/pricing` API (`SetPricingRequest` and `PricingResponse`).

### Default pricing

Default pricing is **off by default**. When you enable it (`default_pricing: true` in `config.yml`, or
Expand Down
72 changes: 72 additions & 0 deletions docs/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2445,6 +2445,28 @@
"ModelPricingInfo": {
"description": "Pricing information for a model.",
"properties": {
"cache_read_price_per_million": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Cache Read Price Per Million"
},
"cache_write_price_per_million": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Cache Write Price Per Million"
},
"input_price_per_million": {
"title": "Input Price Per Million",
"type": "number"
Expand Down Expand Up @@ -2596,6 +2618,28 @@
"PricingResponse": {
"description": "Response model for model pricing.",
"properties": {
"cache_read_price_per_million": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Cache Read Price Per Million"
},
"cache_write_price_per_million": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Cache Write Price Per Million"
},
"created_at": {
"title": "Created At",
"type": "string"
Expand Down Expand Up @@ -2626,6 +2670,8 @@
"effective_at",
"input_price_per_million",
"output_price_per_million",
"cache_read_price_per_million",
"cache_write_price_per_million",
"created_at",
"updated_at"
],
Expand Down Expand Up @@ -3369,6 +3415,32 @@
"SetPricingRequest": {
"description": "Request model for setting model pricing.",
"properties": {
"cache_read_price_per_million": {
"anyOf": [
{
"minimum": 0.0,
"type": "number"
},
{
"type": "null"
}
],
"description": "Price per 1M cached-input tokens",
"title": "Cache Read Price Per Million"
},
"cache_write_price_per_million": {
"anyOf": [
{
"minimum": 0.0,
"type": "number"
},
{
"type": "null"
}
],
"description": "Price per 1M cache-write (creation) tokens",
"title": "Cache Write Price Per Million"
},
"effective_at": {
"anyOf": [
{
Expand Down
73 changes: 68 additions & 5 deletions src/gateway/api/routes/_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No lower-bound clamp: on the cache_tokens_in_prompt=True (OpenAI/Gemini) branch, total_input_tokens == prompt_tokens, so if cache_read ever exceeds prompt_tokens this goes negative and subtracts from cost (undercharge). Today the invariant holds, but the streaming merge combines counts from different chunks with or, so a provider quirk could break it. This is money math, a max(0, ...) clamp is cheap defense.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 prompt_tokens, completion_tokens, or cache counts. With a configured cache rate, a negative cache count can still produce a negative cost and credit budget. Normalize all token counts to >= 0 before pricing.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
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
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))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/api/routes/_pipeline.py` around lines 988 - 1022, Normalize
prompt_tokens, completion_tokens, cache_read, and cache_write to non-negative
values before calculating totals and applying cache clamps in the pricing flow.
Update the assignments around usage_data and cache_tokens_of so malformed
negative counts become zero, while preserving the existing cache-rate pricing
behavior and cost calculation.



def _elapsed_ms(started_at: float | None) -> int | None:
"""Milliseconds elapsed since a monotonic ``started_at`` reading.

Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/gateway/api/routes/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ def _messages_stream_usage(event: MessageStreamEvent) -> CompletionUsage | None:
total_tokens=input_tokens + output_tokens,
cache_read_tokens=event.usage.cache_read_input_tokens or 0,
cache_write_tokens=event.usage.cache_creation_input_tokens or 0,
cache_tokens_in_prompt=False,
)
if isinstance(event, MessageStartEvent):
usage = event.message.usage
Expand All @@ -194,6 +195,7 @@ def _messages_stream_usage(event: MessageStreamEvent) -> CompletionUsage | None:
total_tokens=input_tokens,
cache_read_tokens=cache_read,
cache_write_tokens=cache_write,
cache_tokens_in_prompt=False,
)
return None

Expand Down Expand Up @@ -239,6 +241,7 @@ def extract_usage(self, result: MessageResponse) -> CompletionUsage | None:
total_tokens=result.usage.input_tokens + result.usage.output_tokens,
cache_read_tokens=result.usage.cache_read_input_tokens or 0,
cache_write_tokens=result.usage.cache_creation_input_tokens or 0,
cache_tokens_in_prompt=False,
)

async def call_provider(self, kwargs: dict[str, Any]) -> MessageResponse:
Expand Down
12 changes: 12 additions & 0 deletions src/gateway/api/routes/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ class ModelPricingInfo(BaseModel):

input_price_per_million: float
output_price_per_million: float
cache_read_price_per_million: float | None = None
cache_write_price_per_million: float | None = None


class ModelObject(BaseModel):
Expand Down Expand Up @@ -183,6 +185,8 @@ def _model_from_pricing(pricing: ModelPricing) -> ModelObject:
pricing=ModelPricingInfo(
input_price_per_million=pricing.input_price_per_million,
output_price_per_million=pricing.output_price_per_million,
cache_read_price_per_million=pricing.cache_read_price_per_million,
cache_write_price_per_million=pricing.cache_write_price_per_million,
),
pricing_source="configured",
context_window=_context_window_for_key(pricing.model_key),
Expand All @@ -209,6 +213,8 @@ def _alias_model(
pricing=ModelPricingInfo(
input_price_per_million=pricing.input_price_per_million,
output_price_per_million=pricing.output_price_per_million,
cache_read_price_per_million=pricing.cache_read_price_per_million,
cache_write_price_per_million=pricing.cache_write_price_per_million,
)
if pricing
else None,
Expand Down Expand Up @@ -265,6 +271,8 @@ def _apply_default_pricing(obj: ModelObject, pricing_selector: str | None = None
obj.pricing = ModelPricingInfo(
input_price_per_million=default.input_price_per_million,
output_price_per_million=default.output_price_per_million,
cache_read_price_per_million=default.cache_read_price_per_million,
cache_write_price_per_million=default.cache_write_price_per_million,
)
obj.pricing_source = "default"

Expand Down Expand Up @@ -354,6 +362,8 @@ async def list_models(
pricing=ModelPricingInfo(
input_price_per_million=pricing.input_price_per_million,
output_price_per_million=pricing.output_price_per_million,
cache_read_price_per_million=pricing.cache_read_price_per_million,
cache_write_price_per_million=pricing.cache_write_price_per_million,
)
if pricing
else None,
Expand Down Expand Up @@ -550,6 +560,8 @@ async def get_model(
pricing=ModelPricingInfo(
input_price_per_million=pricing.input_price_per_million,
output_price_per_million=pricing.output_price_per_million,
cache_read_price_per_million=pricing.cache_read_price_per_million,
cache_write_price_per_million=pricing.cache_write_price_per_million,
)
if pricing
else None,
Expand Down
Loading
Loading