Skip to content
Open
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
14 changes: 13 additions & 1 deletion api/chute/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -2172,12 +2172,24 @@ async def get_and_store_llm_details(chute_id: str):
model_info["price"] = price
model_info["confidential_compute"] = chute.tee

# OpenRouter format.
# OpenRouter format, plus the Hugging Face aliases. HF's
# /v1/models contract asks for `pricing.input` / `pricing.output`
# (USD per million tokens) and powers their provider comparison
# table and the `:cheapest` routing policy. Same numbers, extra
# keys only — nothing that reads `prompt`/`completion` changes.
model_info["pricing"] = {
"prompt": per_million_in,
"completion": per_million_out,
"input_cache_read": input_cache_read,
"input": per_million_in,
"output": per_million_out,
}

# HF also requires `context_length` on every listed model. Most
# instances report it already; fall back to max_model_len for the
# few that don't rather than leaving the field absent.
if not model_info.get("context_length") and model_info.get("max_model_len"):
model_info["context_length"] = model_info["max_model_len"]
if chute.llm_detail and isinstance(chute.llm_detail.overrides, dict):
model_info.update(
{
Expand Down
8 changes: 8 additions & 0 deletions api/invocation/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,14 @@ def build_response_headers(request, base_headers=None):
Build response headers dict with quota, rate limit, and invoice billing info.
"""
headers = dict(base_headers or {})

# Mirror the invocation ID onto `Inference-Id`. Hugging Face uses this header
# to key the billing callback in api/partners/router.py; it is the name they
# suggest to providers who don't already have one. Purely additive —
# X-Chutes-InvocationID stays exactly as it is.
if "X-Chutes-InvocationID" in headers and "Inference-Id" not in headers:
headers["Inference-Id"] = headers["X-Chutes-InvocationID"]

if getattr(request.state, "quota_total", None) is not None:
headers["X-Chutes-Quota-Total"] = str(int(request.state.quota_total))
headers["X-Chutes-Quota-Used"] = str(int(request.state.quota_used))
Expand Down
2 changes: 2 additions & 0 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from api.e2e.router import router as e2e_router
from api.encrypted_logs.router import router as encrypted_logs_router
from api.model_alias.router import router as model_alias_router
from api.partners.router import router as partners_router
from api.chute.util import chute_id_by_slug
from api.database import Base, engine, get_session
from api.config import settings
Expand Down Expand Up @@ -173,6 +174,7 @@ async def lifespan(_: FastAPI):
encrypted_logs_router, prefix="/encrypted_logs", tags=["Encrypted Logs"]
)
default_router.include_router(model_alias_router, prefix="/model_aliases", tags=["Model Aliases"])
default_router.include_router(partners_router, prefix="/partners", tags=["Partners"])


# Do not use app for this, else middleware picks it up
Expand Down
Empty file added api/partners/__init__.py
Empty file.
107 changes: 107 additions & 0 deletions api/partners/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""
Partner integration endpoints.

Currently only Hugging Face, which requires providers to expose a billing
callback so HF can charge its users the provider's real per-request cost:
https://huggingface.co/docs/inference-providers/en/register-as-a-provider#4-billing
"""

import math
from typing import Optional

from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession

from api.database import get_db_session
from api.user.schemas import User
from api.user.service import get_current_user

router = APIRouter()

# HF sends batches of up to 10,000 request IDs per call.
MAX_REQUEST_IDS = 10000

# HF gives up on a request roughly 30 minutes after it was served, so there is no
# point scanning further back than that (and it keeps the index range tight).
LOOKBACK_INTERVAL = "2 hours"

NANO_USD = 1_000_000_000


class BillingRequest(BaseModel):
requestIds: list[str] = Field(default_factory=list)


class BillingEntry(BaseModel):
requestId: str
costNanoUsd: int


class BillingResponse(BaseModel):
requests: Optional[list[BillingEntry]]


@router.post("/huggingface/billing", response_model=BillingResponse)
async def huggingface_billing(
body: BillingRequest,
db: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_user()),
):
"""
Return the cost, in nano-USD, of previously served invocations.

Hugging Face polls this once a minute with the IDs it routed to us, using the
same bearer auth as inference. Costs are read from the per-invocation billed
balance already persisted in `invocations.metrics->>'b'` (USD), so this
reports exactly what the user was charged — no separate pricing path to keep
in sync.

Two behaviours are load-bearing and come straight from HF's spec:

* Unknown IDs are OMITTED, never returned as 0. HF replaces its placeholder
cost with whatever we return and never asks again, so answering 0 for an
invocation that simply hasn't been written yet would bill it as free,
permanently. Omitted IDs get retried every minute for ~30 minutes.
* A batch we know nothing about returns `{"requests": null}`, HF's documented
"no data yet, retry later" signal.

Scoped to the authenticated user so one partner key can never read another
account's costs.
"""
request_ids = list(dict.fromkeys(body.requestIds or []))[:MAX_REQUEST_IDS]
if not request_ids:
return BillingResponse(requests=None)

result = await db.execute(
text(f"""
SELECT
invocation_id,
(metrics->>'b')::float AS billed_usd
FROM invocations
WHERE
invocation_id = ANY(:request_ids)
AND user_id = :user_id
AND completed_at >= NOW() - INTERVAL '{LOOKBACK_INTERVAL}'
AND metrics->>'b' IS NOT NULL
"""),
{"request_ids": request_ids, "user_id": current_user.user_id},
)

entries = []
for row in result.fetchall():
if row.billed_usd is None or row.billed_usd < 0:
# Unparseable or negative: omit so HF retries rather than locking in
# a value it would otherwise treat as final.
continue
# HF requires a non-negative integer and rounds non-integers up, so do it
# here explicitly. 0 is valid and means "served for free".
entries.append(
BillingEntry(
requestId=row.invocation_id,
costNanoUsd=math.ceil(row.billed_usd * NANO_USD),
)
)

return BillingResponse(requests=entries or None)
2 changes: 1 addition & 1 deletion charts/templates/api-ingress.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ metadata:
nginx.ingress.kubernetes.io/cors-allow-credentials: "false"
nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-Chute-Id,X-Instance-Id,X-E2E-Nonce,X-E2E-Stream,X-E2E-Path,X-Enable-Thinking,X-Provider,X-Provider-Version,X-Title"
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, OPTIONS, PUT, DELETE, PATCH"
nginx.ingress.kubernetes.io/cors-expose-headers: "X-Chutes-Quota-Total,X-Chutes-Quota-Used,X-Chutes-Quota-Remaining,X-Chutes-RL-User,X-Chutes-RL-Chute,X-Chutes-InvocationID"
nginx.ingress.kubernetes.io/cors-expose-headers: "X-Chutes-Quota-Total,X-Chutes-Quota-Used,X-Chutes-Quota-Remaining,X-Chutes-RL-User,X-Chutes-RL-Chute,X-Chutes-InvocationID,Inference-Id"
nginx.ingress.kubernetes.io/cors-max-age: "1728000"
nginx.ingress.kubernetes.io/proxy-body-size: "0"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
Expand Down
Loading