Compare commits

..
Author SHA1 Message Date
9qeklajc 0c7373675f solidify logic 2026-06-27 22:53:35 +02:00
9qeklajc a33ea5da07 update-pricing 2026-06-27 16:01:45 +02:00
8 changed files with 178 additions and 205 deletions
-8
View File
@@ -34,14 +34,6 @@ UPSTREAM_API_KEY=your-upstream-api-key
# LOG_LEVEL=INFO
# ENABLE_CONSOLE_LOGGING=true
# SentryStr: mirror logs to Nostr relays (requires `pip install sentrystr`).
# Announcements run on a background worker thread so they never block requests.
# ENABLE_SENTRYSTR=false
# SENTRYSTR_LOG_LEVEL=ERROR # defaults to ERROR; keep >= LOG_LEVEL
# SENTRYSTR_RELAYS="wss://relay.damus.io,wss://nos.lol" # defaults to RELAYS
# SENTRYSTR_NSEC=nsec1... # defaults to NSEC; ephemeral key if unset
# SENTRYSTR_RECIPIENT_NPUB=npub1... # if set, log events are NIP-44 encrypted
# Custom Model Management
# BASE_URL=https://openrouter.ai/api/v1
# MODELS_PATH=models.json
-5
View File
@@ -23,11 +23,6 @@ dependencies = [
"litellm>=1.55.0",
]
[project.optional-dependencies]
# Decentralized log mirroring to Nostr relays. Enable at runtime with
# ENABLE_SENTRYSTR=true (see routstr/core/logging.py).
sentrystr = ["sentrystr>=0.2.0"]
[dependency-groups]
dev = [
"mypy>=1.15.0",
+21 -150
View File
@@ -232,46 +232,31 @@ class SecurityFilter(logging.Filter):
"refund_address",
}
def _scrub(self, message: str) -> str:
"""Redact secrets from a single string."""
standalone_patterns = [
r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives)
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
r"nsec[a-z0-9]+", # Nostr Public / Private Key
]
for pattern in standalone_patterns:
message = re.sub(pattern, "[REDACTED]", message, flags=re.IGNORECASE)
for key in self.SENSITIVE_KEYS:
if key in message.lower():
key_patterns = [
rf"{key}\s*[:=]\s*([a-zA-Z0-9_\-\.=/+]+)", # key:value or key=value (including any variant with spaces)
rf'{key}\s*[:=]\s*["\']([^"\']+)["\']', # key:"value" or key='value' (including any variant with spaces)
]
for pattern in key_patterns:
message = re.sub(
pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE
)
return message
def filter(self, record: logging.LogRecord) -> bool:
"""Filter out sensitive information from log records."""
try:
message = redact_org_ids(record.getMessage())
record.msg = self._scrub(message)
record.args = ()
message = record.getMessage()
message = redact_org_ids(message)
standalone_patterns = [
r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives)
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
r"nsec[a-z0-9]+", # Nostr Public / Private Key
]
for pattern in standalone_patterns:
message = re.sub(pattern, "[REDACTED]", message, flags=re.IGNORECASE)
# The exception traceback is appended by the *formatter* (e.g. when a
# QueueHandler flattens the record before it is announced to the
# public, permanent SentryStr relays), so the message scrub above
# never sees it. Pre-render and redact it here and cache the result
# in `exc_text` so every formatter reuses the scrubbed version.
if record.exc_info and record.exc_info[0] is not None and not record.exc_text:
record.exc_text = logging.Formatter().formatException(record.exc_info)
if record.exc_text:
record.exc_text = self._scrub(redact_org_ids(record.exc_text))
if record.stack_info:
record.stack_info = self._scrub(redact_org_ids(record.stack_info))
for key in self.SENSITIVE_KEYS:
if key in message.lower():
key_patterns = [
rf"{key}\s*[:=]\s*([a-zA-Z0-9_\-\.=/+]+)", # key:value or key=value (including any variant with spaces)
rf'{key}\s*[:=]\s*["\']([^"\']+)["\']', # key:"value" or key='value' (including any variant with spaces)
]
for pattern in key_patterns:
message = re.sub(
pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE
)
record.msg = message
record.args = ()
# Structured `extra={...}` fields are emitted by the JSON formatter
# straight from the record dict and never pass through the message
@@ -467,120 +452,6 @@ def setup_logging() -> None:
logging.config.dictConfig(LOGGING_CONFIG)
_install_sentrystr_logging(LOGGING_CONFIG)
# Handle for the background SentryStr logging worker (None when disabled).
_sentrystr_handle: Any = None
# Bound on the in-memory queue feeding the background SentryStr worker. Records
# beyond this are dropped rather than blocking the caller or growing memory
# without limit when a relay is slow or unreachable.
_SENTRYSTR_QUEUE_SIZE = 10_000
# Max seconds to wait for the queued backlog to flush on shutdown before
# abandoning it, so shutdown cannot hang on a slow or unreachable relay.
_SENTRYSTR_SHUTDOWN_TIMEOUT = 10.0
def _install_sentrystr_logging(logging_config: dict[str, Any]) -> None:
"""Mirror routstr application logs to Nostr relays via SentryStr.
Announcing to relays blocks, so SentryStr's installer runs the publish on a
dedicated background worker thread fed by an in-memory queue; the request /
event-loop threads only pay the cost of enqueueing a record. Disabled unless
`ENABLE_SENTRYSTR` is set. Any failure here is swallowed so optional logging
can never take down startup.
"""
global _sentrystr_handle
if _sentrystr_handle is not None:
return
try:
from .settings import settings
except Exception:
return
if not getattr(settings, "enable_sentrystr", False):
return
routstr_logger = logging.getLogger("routstr")
relays = list(settings.sentrystr_relays or settings.relays or [])
if not relays:
routstr_logger.warning(
"SentryStr logging is enabled but no relays are configured; set "
"SENTRYSTR_RELAYS or RELAYS. Skipping SentryStr logging."
)
return
try:
from sentrystr import install_sentrystr_logging
except Exception:
routstr_logger.warning(
"SentryStr logging is enabled but the `sentrystr` package is not "
"installed. Install it with `pip install sentrystr`. Skipping "
"SentryStr logging."
)
return
# Default to ERROR: these events are announced to public, permanent relays,
# so only genuine problems should be mirrored unless the operator opts into
# something more verbose via SENTRYSTR_LOG_LEVEL. (Keep this at or above
# LOG_LEVEL; a lower value is gated out by the routstr loggers' own level.)
level = (settings.sentrystr_log_level or "ERROR").upper()
# routstr application loggers set propagate=False, so the handler must be
# attached to each of them rather than relying on propagation to the root.
routstr_loggers = [
name
for name in logging_config.get("loggers", {})
if name == "routstr" or name.startswith("routstr.")
] or ["routstr"]
try:
_sentrystr_handle = install_sentrystr_logging(
relays=relays,
private_key=(settings.sentrystr_nsec or settings.nsec or None),
level=level,
loggers=routstr_loggers,
recipient_pubkey=(settings.sentrystr_recipient_npub or None),
platform="routstr",
formatter=logging.Formatter("%(message)s"),
# Run the same scrubbing/enrichment filters used by the other
# handlers, on the calling thread, before records are handed to the
# background worker. RequestIdFilter must run here because it reads a
# ContextVar that is only set on the request thread; SecurityFilter
# must run here so secrets are redacted before anything is announced
# to the (public, permanent) relays. Passing them to the installer
# attaches them before the handler goes live, so no record can slip
# through unfiltered during startup.
filters=[RequestIdFilter(), SecurityFilter()],
# Bound the queue so a slow/unreachable relay can't grow memory.
queue_size=_SENTRYSTR_QUEUE_SIZE,
)
except Exception as exc:
routstr_logger.warning("Failed to initialize SentryStr logging: %s", exc)
return
routstr_logger.info(
"SentryStr logging enabled",
extra={"sentrystr_level": level, "sentrystr_relay_count": len(relays)},
)
def shutdown_sentrystr_logging() -> None:
"""Flush and stop the background SentryStr logging worker, if running."""
global _sentrystr_handle
handle = _sentrystr_handle
_sentrystr_handle = None
if handle is not None:
try:
handle.close(timeout=_SENTRYSTR_SHUTDOWN_TIMEOUT)
except Exception:
pass
def get_logger(name: str) -> logging.Logger:
"""Get a logger instance for the given module name."""
-7
View File
@@ -230,13 +230,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
extra={"error": str(e), "error_type": type(e).__name__},
)
# Flush and stop the background SentryStr logging worker (no-op when
# SentryStr logging is disabled). Run off the event loop: the drain is
# blocking (though bounded) and must not stall the loop during shutdown.
from .logging import shutdown_sentrystr_logging
await asyncio.to_thread(shutdown_sentrystr_logging)
class _ImmutableStaticFiles(StaticFiles):
"""Static files with long Cache-Control for content-hashed Next.js assets.
+1 -21
View File
@@ -16,12 +16,7 @@ class Settings(BaseSettings):
@classmethod
def parse_env_var(cls, field_name: str, raw_value: str) -> Any: # type: ignore[override]
if field_name in {
"cashu_mints",
"cors_origins",
"relays",
"sentrystr_relays",
}:
if field_name in {"cashu_mints", "cors_origins", "relays"}:
v = str(raw_value).strip()
if v == "":
return []
@@ -108,21 +103,6 @@ class Settings(BaseSettings):
log_level: str = Field(default="INFO", env="LOG_LEVEL")
enable_console_logging: bool = Field(default=True, env="ENABLE_CONSOLE_LOGGING")
# SentryStr (decentralized logging to Nostr relays). When enabled, routstr
# application logs at `sentrystr_log_level` and above are mirrored to Nostr
# relays on a background worker thread (see routstr/core/logging.py).
enable_sentrystr: bool = Field(default=False, env="ENABLE_SENTRYSTR")
# Minimum level forwarded to relays. Empty -> defaults to ERROR. Should be
# at or above `log_level`, since the routstr loggers gate lower records.
sentrystr_log_level: str = Field(default="", env="SENTRYSTR_LOG_LEVEL")
# Relays to announce to. Empty -> fall back to the discovery `relays`.
sentrystr_relays: list[str] = Field(default_factory=list, env="SENTRYSTR_RELAYS")
# Secret key (nsec or hex) used to sign log events. Empty -> fall back to
# `nsec`; if that is also empty an ephemeral key is generated per process.
sentrystr_nsec: str = Field(default="", env="SENTRYSTR_NSEC")
# Optional recipient npub. When set, log events are NIP-44 encrypted to it.
sentrystr_recipient_npub: str = Field(default="", env="SENTRYSTR_RECIPIENT_NPUB")
# Other
chat_completions_api_version: str = Field(
default="", env="CHAT_COMPLETIONS_API_VERSION"
+38 -5
View File
@@ -94,7 +94,10 @@ def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing:
cache reads (DeepSeek hits are 10x cheaper) and undercharges Anthropic
cache writes (1.25x). litellm ships per-model USD rates keyed by the exact
OpenRouter id (deepseek/deepseek-chat) or by the bare model name
(gpt-4o, claude-sonnet-4-5), so both spellings are tried.
(gpt-4o, claude-sonnet-4-5), so both spellings are tried. litellm keys are
lowercase, but a generic upstream may report a mixed-case id
(``deepseek-ai/DeepSeek-V4-Flash``); an exact match is attempted first, then
a case-insensitive fallback so such ids still resolve.
Rates already present (e.g. provided by OpenRouter) are authoritative and
never overwritten. Unknown models are returned unchanged.
@@ -106,12 +109,26 @@ def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing:
import litellm
candidates = (model_id, model_id.split("/", 1)[-1])
info: dict | None = None
for key in (model_id, model_id.split("/", 1)[-1]):
for key in candidates:
candidate = litellm.model_cost.get(key)
if isinstance(candidate, dict):
info = candidate
break
if info is None:
# Case-insensitive fallback: a mixed-case upstream id (e.g.
# ``deepseek-ai/DeepSeek-V4-Flash``) won't match litellm's lowercase
# keys exactly. Build a lowercased index once and retry.
lowered = {c.lower() for c in candidates}
for key, candidate in litellm.model_cost.items():
if (
isinstance(key, str)
and key.lower() in lowered
and isinstance(candidate, dict)
):
info = candidate
break
if info is None:
return pricing
@@ -215,13 +232,29 @@ def _row_to_model(
)
top_provider_dict = json.loads(row.top_provider) if row.top_provider else None
if apply_provider_fee and isinstance(pricing, dict):
pricing = {k: float(v) * provider_fee for k, v in pricing.items()}
if isinstance(pricing, dict) and float(pricing.get("request", 0.0)) <= 0.0:
pricing["request"] = max(pricing.get("request", 0.0), 0.0)
parsed_pricing = Pricing.parse_obj(pricing)
# Fill missing cache-read/write rates from litellm's cost map BEFORE applying
# the provider fee, so they carry the same markup as every other component.
# DB-stored override pricing (e.g. generic providers) omits cache rates;
# without this, ``_row_to_model`` bills cache reads at the full input rate —
# the ``_apply_provider_fee_to_model`` path backfills, but the override path
# used for admin-configured providers did not.
#
# Key on ``forwarded_model_id`` (the actual upstream model name litellm
# prices) when set: an alias row (id="local-alias",
# forwarded_model_id="deepseek-v4-flash") would otherwise look up the alias
# and miss the cache rate.
pricing_model_id = getattr(row, "forwarded_model_id", None) or row.id
parsed_pricing = backfill_cache_pricing(pricing_model_id, parsed_pricing)
if apply_provider_fee:
parsed_pricing = Pricing.parse_obj(
{k: float(v) * provider_fee for k, v in parsed_pricing.dict().items()}
)
model = Model(
id=row.id,
name=row.name,
+16 -9
View File
@@ -3,10 +3,15 @@
litellm's bundled cost map does not yet ship ``deepseek-v4-flash`` /
``deepseek-v4-pro``. Without an entry, ``backfill_cache_pricing`` cannot find a
``cache_read_input_token_cost`` and cache reads fall back to the full input
rate — a ~60% overcharge on cache hits (DeepSeek hits are ~0.2x input).
rate — a large overcharge on cache hits (DeepSeek V4 hits are ~0.008-0.02x
input, i.e. cached tokens cost 50-120x less than regular input).
This module injects the missing entries into ``litellm.model_cost`` at startup
so the existing backfill path resolves them. Rates mirror the open upstream PR
so the existing backfill path resolves them. Rates mirror the canonical
``deepseek`` provider entries now in litellm's ``model_prices`` map
(``input_cost_per_token`` is the cache-*miss* rate;
``cache_read_input_token_cost`` is the cache-*hit* rate), sourced from
https://api-docs.deepseek.com/quick_start/pricing via
https://github.com/BerriAI/litellm/pull/26380 (issue
https://github.com/BerriAI/litellm/issues/30430).
@@ -22,21 +27,23 @@ from ..core import get_logger
logger = get_logger(__name__)
# USD per token. Source: BerriAI/litellm PR #26380.
# USD per token. Mirrors the canonical ``deepseek`` provider entries in
# litellm's model_prices map (source: DeepSeek API pricing docs). Keep these in
# sync with ``litellm.model_cost["deepseek/deepseek-v4-*"]``.
_DEEPSEEK_V4_RATES: dict[str, dict[str, float]] = {
"deepseek-v4-flash": {
"input_cost_per_token": 1.4e-07,
"output_cost_per_token": 2.8e-07,
"cache_read_input_token_cost": 2.8e-08,
"cache_read_input_token_cost": 2.8e-09,
"cache_creation_input_token_cost": 0.0,
"input_cost_per_token_cache_hit": 2.8e-08,
"input_cost_per_token_cache_hit": 2.8e-09,
},
"deepseek-v4-pro": {
"input_cost_per_token": 1.74e-06,
"output_cost_per_token": 3.48e-06,
"cache_read_input_token_cost": 1.4e-07,
"input_cost_per_token": 4.35e-07,
"output_cost_per_token": 8.7e-07,
"cache_read_input_token_cost": 3.625e-09,
"cache_creation_input_token_cost": 0.0,
"input_cost_per_token_cache_hit": 1.4e-07,
"input_cost_per_token_cache_hit": 3.625e-09,
},
}
+102
View File
@@ -81,6 +81,21 @@ def test_backfill_strips_vendor_prefix_for_litellm_lookup() -> None:
assert result.input_cache_read == expected
def test_backfill_case_insensitive_lookup() -> None:
"""A generic upstream may report a mixed-case id
(deepseek-ai/DeepSeek-V4-Flash); litellm keys are lowercase. The
case-insensitive fallback still resolves the cache rate."""
pricing = Pricing(prompt=1.4e-07, completion=2.8e-07)
result = backfill_cache_pricing("deepseek-ai/DeepSeek-V4-Flash", pricing)
expected = litellm.model_cost["deepseek-v4-flash"][
"cache_read_input_token_cost"
]
assert result.input_cache_read == expected
assert result.input_cache_read < pricing.prompt # sanity: it's a discount
def test_backfill_fills_cache_write_rate() -> None:
"""Anthropic cache writes cost more than input (1.25x); billing them at
the input rate undercharges. litellm carries the write rate."""
@@ -136,6 +151,93 @@ def test_provider_fee_applies_to_backfilled_cache_rates() -> None:
assert adjusted.pricing.prompt == pytest.approx(2.8e-07 * 2.0)
def test_row_to_model_backfills_cache_rate() -> None:
"""The DB-override path (admin-configured providers, e.g. a generic
upstream) stores pricing without cache rates. ``_row_to_model`` must
backfill them from litellm just like ``_apply_provider_fee_to_model``,
otherwise cache reads bill at the full input rate."""
import json
from routstr.core.db import ModelRow
from routstr.payment.models import _row_to_model
row = ModelRow(
id="deepseek-v4-flash",
name="deepseek-v4-flash",
created=0,
description="",
context_length=1000000,
architecture=json.dumps(
{
"modality": "text",
"input_modalities": ["text"],
"output_modalities": ["text"],
"tokenizer": "unknown",
"instruct_type": None,
}
),
# Stored pricing omits input_cache_read (generic provider never sets it).
pricing=json.dumps({"prompt": 1.4e-07, "completion": 2.8e-07}),
enabled=True,
upstream_provider_id=1,
)
with patch(
"routstr.payment.models.sats_usd_price", return_value=5.0e-5
):
model = _row_to_model(row, apply_provider_fee=True, provider_fee=1.0)
litellm_read = litellm.model_cost["deepseek-v4-flash"][
"cache_read_input_token_cost"
]
assert model.pricing.input_cache_read == pytest.approx(litellm_read)
assert model.pricing.input_cache_read < model.pricing.prompt # a discount
assert model.sats_pricing is not None
assert model.sats_pricing.input_cache_read > 0
def test_row_to_model_backfills_via_forwarded_model_id() -> None:
"""An alias row (id != forwarded_model_id) must backfill cache rates from
the *forwarded* model name — the real upstream model litellm prices —
not the alias id, which litellm doesn't know."""
import json
from routstr.core.db import ModelRow
from routstr.payment.models import _row_to_model
row = ModelRow(
id="local-alias", # litellm has no such key
name="local-alias",
created=0,
description="",
context_length=1000000,
architecture=json.dumps(
{
"modality": "text",
"input_modalities": ["text"],
"output_modalities": ["text"],
"tokenizer": "unknown",
"instruct_type": None,
}
),
pricing=json.dumps({"prompt": 1.4e-07, "completion": 2.8e-07}),
enabled=True,
upstream_provider_id=1,
forwarded_model_id="deepseek-v4-flash",
)
with patch(
"routstr.payment.models.sats_usd_price", return_value=5.0e-5
):
model = _row_to_model(row, apply_provider_fee=True, provider_fee=1.0)
litellm_read = litellm.model_cost["deepseek-v4-flash"][
"cache_read_input_token_cost"
]
assert model.pricing.input_cache_read == pytest.approx(litellm_read)
assert model.pricing.input_cache_read < model.pricing.prompt
# ============================================================================
# calculate_cost — cached tokens billed at cache rates
# ============================================================================