unsloth/studio/backend/routes/providers.py
Daniel Han 2201fd687b
Studio: per-session cost calculator + /api/providers/pricing endpoint (#5690)
* Studio: per-session cost calculator + /api/providers/pricing endpoint

Neither the Anthropic Messages API nor the OpenAI Responses API
reports a `cost` field on the response. Both expose detailed token
counts (input, output, cache hits, server-tool invocations); pricing
multipliers live in the provider docs. The frontend's "cost so far"
display was impossible without scraping the server log.

Land the math + a snapshot endpoint so the cost calculator can run
client-side from the existing usage chunk plumbing. The actual UI
hookup belongs in a frontend follow-up (and is gated on PR #5670's
usage-chunk emission landing so the frontend sees the usage block
in the first place).

Changes:

- New `core/inference/pricing.py` with:
  - Per-MTok base pricing tables for every active Anthropic and
    gpt-5.x family member. Dated snapshots inherit the canonical-id
    price via prefix match so future snapshots cost the same as the
    canonical id until pricing changes.
  - Shared multipliers for Anthropic cache writes (5m: 1.25x, 1h: 2x)
    and reads (0.1x); OpenAI cache reads (0.1x); Anthropic server
    tool surcharges ($10 / 1k web_search, $0.05 / hour code_exec
    beyond the 50-hour daily free tier).
  - `calculate_cost(provider, model, usage)` returns a per-turn USD
    breakdown plus billable token counts, with priced=False for
    unknown models so the UI can still render token counts.
  - `pricing_snapshot()` returns the whole table for the frontend
    so it doesn't re-implement the multipliers.
- New `GET /api/providers/pricing` returning the snapshot, scoped
  behind the existing auth dependency.
- New `backend/tests/test_pricing.py` with 12 cases pinning the
  math against documented values: base input/output multiplication,
  5m / 1h / read multipliers, default-to-5m fallback when the
  breakdown is absent, web_search per-1k pricing, code_execution
  per-hour pricing, dated-snapshot fallback, OpenAI cache-read
  discount accounting (cached tokens subtracted from full-price
  bucket and re-billed at 0.1x), unknown model graceful-degrade,
  and the snapshot endpoint shape.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: verified OpenAI pricing + fix billable input double-count

Address the cost-calculator review:

- OpenAI prices were 2-6x under the actual published rates.
  Cross-checked the live developers.openai.com/api/docs/pricing page
  and replaced every entry. gpt-5.5 is 5/30, gpt-5.5-pro is 30/180,
  gpt-5.4 is 2.5/15, gpt-5.4-mini 0.75/4.5, gpt-5.4-nano 0.20/1.25,
  gpt-5.3-codex 1.75/14. Added chat-latest alias to the canonical
  chat-snapshot rate. Dropped o3 / o4 / gpt-4.5 rows that are no
  longer listed on the page; calculator returns priced=False instead
  of silently billing at zero.

- billable_input_tokens was double-counting cached tokens for
  OpenAI. Anthropic excludes cache_* buckets from input_tokens so
  we add them; OpenAI folds cache_read_input_tokens into
  input_tokens already, so the tooltip read 1.8M for a 1.0M bill.
  Branched the math by provider and added a regression test.

Sourcing notes in the module docstring updated.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address review: canonical 4.5 ids, long-context tier, OpenAI tool fees

Three Codex P1 follow-ups on the cost calculator:

1. Canonical Anthropic 4.5 ids missing from ANTHROPIC_PRICING.
   claude-opus-4-5 / claude-sonnet-4-5 / claude-haiku-4-5 (no date
   suffix) are the ids used by backend defaults
   (PROVIDER_REGISTRY['anthropic'].default_models), but the table
   only had the dated forms. _lookup's prefix fallback doesn't help
   because the canonical id is SHORTER than the dated key, so
   str.startswith goes the wrong way and the calculator returned
   priced=False + zero cost. Added the canonical aliases for
   opus-4-5, sonnet-4-5, haiku-4-5, and opus-4-1.

2. OpenAI long-context tier. gpt-5.5 and gpt-5.4 cross over at
   272k input tokens to a 2x input / 1.5x output rate (gpt-5.5:
   $5/$30 -> $10/$45; gpt-5.4: $2.50/$15 -> $5/$22.50). Turns past
   the threshold were systematically undercounted at headline
   rates. Added long_context_threshold / long_context_input_per_mtok /
   long_context_output_per_mtok columns and a tier-selection step
   in calculate_cost; model_priced gains a "(long-context >272000)"
   suffix when the higher tier applies so the tooltip can show
   which rate was used. gpt-5.5-pro / gpt-5.4-pro / mini / nano /
   codex have no published long-context tier today, so they keep a
   single rate.

3. OpenAI server-tool surcharges. web_search is $10/1000 calls and
   the hosted shell container is $0.03 per 20-minute session on the
   default 1g tier (~$0.09/hr). server_tools_usd was previously
   stuck at 0.0 for OpenAI even when web_search and shell tools
   fired, so sessions with tool use understated cost. Added
   OPENAI_WEB_SEARCH_USD_PER_1K and OPENAI_CONTAINER_USD_PER_HOUR
   constants plus a parallel of the Anthropic surcharge block that
   reads counts from usage["openai_tool_use"]. The SSE translator
   wires the counts in a follow-up commit; the calculator is now
   ready for them. pricing_snapshot also exposes both constants so
   the frontend tooltip can render the per-call rate.

Existing tests updated to stay in the short-context tier where they
were testing base rates; new tests pin canonical 4.5 lookups,
long-context crossover on gpt-5.5/gpt-5.4, the absence of crossover
on mini/nano/codex, and OpenAI tool surcharges (web_search,
container hours, combined total).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-22 06:03:43 -07:00

361 lines
12 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
API routes for external LLM provider management.
Provides endpoints for:
- Discovering available provider types (registry)
- CRUD for saved provider configurations (no API keys stored)
- Fetching the RSA public key for API key encryption
- Testing provider connectivity
- Listing models from a provider
"""
import uuid
import structlog
from fastapi import APIRouter, Depends, HTTPException
from auth.authentication import get_current_subject
from core.inference.key_exchange import (
decrypt_api_key,
get_public_key_fingerprint,
get_public_key_pem,
)
from core.inference.providers import (
get_base_url,
get_provider_info,
list_available_providers,
)
from core.inference.pricing import pricing_snapshot
from core.inference.external_provider import ExternalProviderClient
from models.providers import (
ProviderCreate,
ProviderModelsRequest,
ProviderModelInfo,
ProviderResponse,
ProviderRegistryEntry,
ProviderTestRequest,
ProviderTestResult,
ProviderUpdate,
)
from storage import providers_db
logger = structlog.get_logger(__name__)
router = APIRouter()
# ── Public key for API key encryption ─────────────────────────────
@router.get("/public-key")
async def get_public_key(
current_subject: str = Depends(get_current_subject),
):
"""Return the RSA public key PEM for client-side API key encryption.
The ``fingerprint`` field is a short SHA256 of the PEM and is meant
purely for diagnostics — a mismatch between what the frontend
captured at encrypt time and what the server reports here is a
clear signal that the keypair rotated mid-flight (e.g. the server
re-ran ``init_key_pair`` for any reason).
"""
return {
"public_key": get_public_key_pem(),
"fingerprint": get_public_key_fingerprint(),
}
# ── Provider registry (static) ───────────────────────────────────
@router.get("/registry", response_model = list[ProviderRegistryEntry])
async def list_registry(
current_subject: str = Depends(get_current_subject),
):
"""List all supported provider types with their default configurations."""
return list_available_providers()
# ── Per-MTok pricing snapshot for client-side cost display ──────────
@router.get("/pricing")
async def get_pricing_snapshot(
current_subject: str = Depends(get_current_subject),
):
"""Static per-MTok pricing table the frontend uses to convert
upstream usage chunks into a per-turn USD cost. See
``core/inference/pricing.py`` for sourcing notes; values reflect
the published prices as of the file's last update."""
return pricing_snapshot()
# ── Provider config CRUD ──────────────────────────────────────────
@router.get("/", response_model = list[ProviderResponse])
async def list_provider_configs(
current_subject: str = Depends(get_current_subject),
):
"""List all saved provider configurations."""
rows = providers_db.list_providers()
return [
ProviderResponse(
id = row["id"],
provider_type = row["provider_type"],
display_name = row["display_name"],
base_url = row["base_url"],
is_enabled = bool(row["is_enabled"]),
created_at = row["created_at"],
updated_at = row["updated_at"],
)
for row in rows
]
@router.post("/", response_model = ProviderResponse, status_code = 201)
async def create_provider_config(
payload: ProviderCreate,
current_subject: str = Depends(get_current_subject),
):
"""Create a new saved provider configuration (no API key stored)."""
info = get_provider_info(payload.provider_type)
if info is None:
raise HTTPException(
status_code = 400,
detail = f"Unknown provider type: {payload.provider_type}. "
f"Use GET /api/providers/registry to see available types.",
)
provider_id = uuid.uuid4().hex[:16]
base_url = payload.base_url or info["base_url"]
providers_db.create_provider(
id = provider_id,
provider_type = payload.provider_type,
display_name = payload.display_name,
base_url = base_url,
)
row = providers_db.get_provider(provider_id)
return ProviderResponse(
id = row["id"],
provider_type = row["provider_type"],
display_name = row["display_name"],
base_url = row["base_url"],
is_enabled = bool(row["is_enabled"]),
created_at = row["created_at"],
updated_at = row["updated_at"],
)
@router.put("/{provider_id}", response_model = ProviderResponse)
async def update_provider_config(
provider_id: str,
payload: ProviderUpdate,
current_subject: str = Depends(get_current_subject),
):
"""Update a saved provider configuration."""
existing = providers_db.get_provider(provider_id)
if not existing:
raise HTTPException(status_code = 404, detail = "Provider not found")
updated = providers_db.update_provider(
id = provider_id,
display_name = payload.display_name,
base_url = payload.base_url,
is_enabled = payload.is_enabled,
)
if not updated:
raise HTTPException(status_code = 400, detail = "No fields to update")
row = providers_db.get_provider(provider_id)
return ProviderResponse(
id = row["id"],
provider_type = row["provider_type"],
display_name = row["display_name"],
base_url = row["base_url"],
is_enabled = bool(row["is_enabled"]),
created_at = row["created_at"],
updated_at = row["updated_at"],
)
@router.delete("/{provider_id}", status_code = 204)
async def delete_provider_config(
provider_id: str,
current_subject: str = Depends(get_current_subject),
):
"""Delete a saved provider configuration."""
deleted = providers_db.delete_provider(provider_id)
if not deleted:
raise HTTPException(status_code = 404, detail = "Provider not found")
# ── Test connectivity ─────────────────────────────────────────────
@router.post("/test", response_model = ProviderTestResult)
async def test_provider(
payload: ProviderTestRequest,
current_subject: str = Depends(get_current_subject),
):
"""
Test connectivity to an external provider.
Makes a lightweight GET /models call to verify the API key works.
The encrypted_api_key is decrypted server-side and never stored.
"""
info = get_provider_info(payload.provider_type)
if info is None:
raise HTTPException(
status_code = 400,
detail = f"Unknown provider type: {payload.provider_type}",
)
api_key = ""
if payload.encrypted_api_key:
try:
api_key = decrypt_api_key(payload.encrypted_api_key)
except Exception as exc:
logger.warning(
"Failed to decrypt API key (%s): %s", type(exc).__name__, exc
)
raise HTTPException(
status_code = 400,
detail = "Failed to decrypt API key. The public key may have changed — try refreshing the page.",
)
base_url = payload.base_url or info["base_url"]
client = ExternalProviderClient(
provider_type = payload.provider_type,
base_url = base_url,
api_key = api_key,
timeout = 15.0,
)
try:
if info.get("model_list_mode") == "curated":
await client.verify_models_endpoint_lightweight()
return ProviderTestResult(
success = True,
message = (
"Connected successfully. Full model list is not fetched for this provider — "
"use suggestions and manual model IDs in the dialog."
),
models_count = None,
)
models = await client.list_models()
return ProviderTestResult(
success = True,
message = f"Connected successfully. Found {len(models)} model(s).",
models_count = len(models),
)
except Exception as exc:
logger.warning("Provider test failed for %s: %s", payload.provider_type, exc)
return ProviderTestResult(
success = False,
message = f"Connection failed: {exc}",
models_count = None,
)
finally:
await client.close()
# ── List models from provider ─────────────────────────────────────
@router.post("/models", response_model = list[ProviderModelInfo])
async def list_provider_models(
payload: ProviderModelsRequest,
current_subject: str = Depends(get_current_subject),
):
"""
List models available from an external provider.
The encrypted_api_key is decrypted server-side and never stored.
"""
info = get_provider_info(payload.provider_type)
if info is None:
raise HTTPException(
status_code = 400,
detail = f"Unknown provider type: {payload.provider_type}",
)
api_key = ""
if payload.encrypted_api_key:
try:
api_key = decrypt_api_key(payload.encrypted_api_key)
except Exception as exc:
logger.warning(
"Failed to decrypt API key (%s): %s", type(exc).__name__, exc
)
raise HTTPException(
status_code = 400,
detail = "Failed to decrypt API key. The public key may have changed — try refreshing the page.",
)
if info.get("model_list_mode") == "curated":
return [
ProviderModelInfo(
id = m,
display_name = m,
context_length = None,
owned_by = None,
)
for m in info.get("default_models", [])
]
base_url = payload.base_url or info["base_url"]
client = ExternalProviderClient(
provider_type = payload.provider_type,
base_url = base_url,
api_key = api_key,
timeout = 15.0,
)
try:
models = await client.list_models()
allow_prefixes = info.get("model_id_allow_prefixes")
if allow_prefixes is not None:
prefix_tuple = tuple(str(p) for p in allow_prefixes if str(p))
if prefix_tuple:
models = [m for m in models if m.get("id", "").startswith(prefix_tuple)]
allowlist = info.get("model_id_allowlist")
if allowlist is not None:
models = [m for m in models if allowlist.match(m.get("id", ""))]
deny_exact = info.get("model_id_deny_exact")
if deny_exact is not None:
deny_ids = {str(m) for m in deny_exact if str(m)}
if deny_ids:
models = [m for m in models if m.get("id", "") not in deny_ids]
denylist = info.get("model_id_denylist")
if denylist is not None:
models = [m for m in models if not denylist.search(m.get("id", ""))]
# Apply an optional cap after filtering so registry entries with a
# large remote catalog (e.g. HF Inference Providers) can stay
# picker-sized. No popularity sort happens server-side, so this is
# "first N matches" — pair with default_models for any must-have
# flagship ids.
limit = info.get("model_id_limit")
if isinstance(limit, int) and limit > 0:
models = models[:limit]
return [
ProviderModelInfo(
id = m.get("id", ""),
display_name = m.get("id", ""),
context_length = m.get("context_length") or m.get("context_window"),
owned_by = m.get("owned_by"),
)
for m in models
]
except Exception as exc:
logger.error("Failed to list models from %s: %s", payload.provider_type, exc)
raise HTTPException(
status_code = 502,
detail = f"Failed to list models from {payload.provider_type}: {exc}",
)
finally:
await client.close()