Compare commits

..
Author SHA1 Message Date
Shroominic 4974a22d0f reproducible cursor problems 2025-12-26 17:27:49 +01:00
shroominicandGitHub 634a473f50 Merge pull request #276 from Routstr/force-sats-pricing
Force sats pricing
2025-12-26 12:36:02 +01:00
Shroominic ea655b748b optimize startup time 2025-12-26 12:33:42 +01:00
9qeklajc 2d247ddc8b fmt 2025-12-26 10:29:51 +01:00
9qeklajc c064452aea Merge branch 'v0.2.1' into force-sats-pricing 2025-12-26 10:27:11 +01:00
9qeklajcandGitHub 1c6a603042 Merge pull request #271 from Routstr/embedding-with-aliases
Embedding with aliases
2025-12-26 10:26:38 +01:00
9qeklajc 84b0007b05 force sats pricig 2025-12-26 09:55:15 +01:00
Shroominic a3e8d5fd38 ignore cloudflare headers from logs 2025-12-24 15:09:03 +01:00
14 changed files with 3984 additions and 35 deletions
+44
View File
@@ -0,0 +1,44 @@
# Reproducing Cursor Problems
Each subdirectory contains a `request.json` (the request body) and `response.json` (the error response received).
## Using curl to reproduce
From the `routstr-core/` directory:
```bash
# OpenAI model error
curl -X POST https://staging.routstr.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $API_KEY" \
-d @cursor-problems/openai-model-error/request.json
# Anthropic internal error
curl -X POST https://staging.routstr.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $API_KEY" \
-d @cursor-problems/anthropic-internal-error/request.json
# Model not found error
curl -X POST https://staging.routstr.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $API_KEY" \
-d @cursor-problems/model-not-found-error/request.json
# Upstream rate limit error
curl -X POST https://staging.routstr.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $API_KEY" \
-d @cursor-problems/upstream-rate-limit-error/request.json
```
## Generic pattern
```bash
curl -X POST <API_ENDPOINT> \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $API_KEY" \
-d @cursor-problems/<directory>/request.json
```
The `-d @filename` syntax tells curl to read the request body from a file.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
{
"error": {
"message": "Internal Server Error",
"type": "upstream_error",
"code": 502
},
"request_id": "4a04e4f8-4a31-45f1-8189-455c86fc4e89"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
{
"error": {
"message": "Model 'claude-4.5-sonnet-thinking' not found",
"type": "invalid_model",
"code": 400
},
"request_id": "d410f512-3221-4047-a4ee-9be6e3fabe38"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
{
"error": {
"message": "Input required: specify \"prompt\" or \"messages\"",
"type": "invalid_request_error",
"code": 400
},
"request_id": "586e0aec-351f-413a-8641-ddda4a0cbadf"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
{
"error": {
"message": "Upstream request failed",
"type": "rate_limit_exceeded",
"code": 429
},
"request_id": "80657fc6-4bca-4cb1-945d-ea65ec8a53c4"
}
+5 -5
View File
@@ -78,6 +78,11 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
_update_prices_task = asyncio.create_task(_update_prices())
_initialize_upstreams_task = asyncio.create_task(initialize_upstreams())
# ensure both setup tasks complete
await asyncio.gather(
_update_prices_task, _initialize_upstreams_task, return_exceptions=True
)
btc_price_task = asyncio.create_task(update_prices_periodically())
pricing_task = asyncio.create_task(update_sats_pricing())
if global_settings.models_refresh_interval_seconds > 0:
@@ -91,11 +96,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
if global_settings.providers_refresh_interval_seconds > 0:
providers_task = asyncio.create_task(providers_cache_refresher())
# ensure both setup tasks complete
await asyncio.gather(
_update_prices_task, _initialize_upstreams_task, return_exceptions=True
)
yield
except asyncio.CancelledError:
+10 -1
View File
@@ -55,7 +55,16 @@ class LoggingMiddleware(BaseHTTPMiddleware):
"headers": {
k: v
for k, v in request.headers.items()
if k.lower() not in ["authorization", "x-cashu", "cookie"]
if k.lower()
not in [
"authorization",
"x-cashu",
"cookie",
"cf-connecting-ip",
"cf-ipcountry",
"x-forwarded-for",
"x-real-ip",
]
},
"body_size": len(request_body) if request_body else 0,
},
+9 -2
View File
@@ -382,7 +382,7 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
async def _update_sats_pricing_once() -> None:
"""Update sats pricing once for all provider models (in-memory only)."""
from ..proxy import get_upstreams
from ..proxy import get_upstreams, refresh_model_maps
upstreams = get_upstreams()
sats_to_usd = sats_usd_price()
@@ -399,6 +399,7 @@ async def _update_sats_pricing_once() -> None:
if updated_count > 0:
logger.info("Updated sats pricing", extra={"models_updated": updated_count})
await refresh_model_maps()
async def update_sats_pricing() -> None:
@@ -409,7 +410,13 @@ async def update_sats_pricing() -> None:
except Exception:
pass
await _update_sats_pricing_once()
try:
await _update_sats_pricing_once()
except Exception as e:
logger.warning(
"Initial sats pricing update failed (will retry in loop)",
extra={"error": str(e)},
)
while True:
try:
+17 -21
View File
@@ -80,31 +80,27 @@ def get_unique_models() -> list[Model]:
async def refresh_model_maps() -> None:
"""Refresh global model and provider maps using the cost-based algorithm."""
from sqlalchemy.orm import selectinload
global _model_instances, _provider_map, _unique_models
# Gather database overrides and disabled models
async with create_session() as session:
result = await session.exec(select(ModelRow).where(ModelRow.enabled))
override_rows = result.all()
provider_result = await session.exec(select(UpstreamProviderRow))
providers_by_id = {p.id: p for p in provider_result.all()}
overrides_by_id: dict[str, tuple[ModelRow, float]] = {
row.id: (
row,
providers_by_id[row.upstream_provider_id].provider_fee
if row.upstream_provider_id in providers_by_id
else 1.01,
)
for row in override_rows
if row.upstream_provider_id is not None
}
disabled_result = await session.exec(
select(ModelRow.id).where(ModelRow.enabled == False) # noqa: E712
# Fetch all providers with their models in a single logical operation
query = select(UpstreamProviderRow).options(
selectinload(UpstreamProviderRow.models) # type: ignore
)
disabled_model_ids = {row for row in disabled_result.all()}
result = await session.exec(query)
provider_rows = result.all()
overrides_by_id: dict[str, tuple[ModelRow, float]] = {}
disabled_model_ids: set[str] = set()
for provider in provider_rows:
for model in provider.models:
if model.enabled:
overrides_by_id[model.id] = (model, provider.provider_fee)
else:
disabled_model_ids.add(model.id)
_model_instances, _provider_map, _unique_models = create_model_mappings(
upstreams=_upstreams,
+23 -6
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import os
import re
from typing import TYPE_CHECKING
@@ -7,6 +8,7 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..core.settings import Settings
from sqlmodel import select
from ..core import get_logger
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
@@ -145,6 +147,17 @@ async def refresh_upstreams_models_periodically(
f"Error refreshing models for {upstream.base_url}",
extra={"error": str(e), "error_type": type(e).__name__},
)
try:
from ..payment.models import _update_sats_pricing_once
await _update_sats_pricing_once()
except Exception as e:
logger.warning(f"Failed to update pricing after model refresh: {e}")
from ..proxy import refresh_model_maps
await refresh_model_maps()
except asyncio.CancelledError:
break
except Exception as e:
@@ -166,8 +179,6 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
Seeds database with providers from settings if empty, then loads and instantiates
provider instances from database records, and refreshes their models cache.
"""
from sqlmodel import select
from ..core.settings import settings
async with create_session() as session:
@@ -183,16 +194,16 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
result = await session.exec(select(UpstreamProviderRow))
existing_providers = result.all()
upstreams: list[BaseUpstreamProvider] = []
for provider_row in existing_providers:
async def _init_single_provider(
provider_row: UpstreamProviderRow,
) -> BaseUpstreamProvider | None:
if not provider_row.enabled:
logger.debug(f"Skipping disabled provider: {provider_row.base_url}")
continue
return None
provider = _instantiate_provider(provider_row)
if provider:
await provider.refresh_models_cache()
upstreams.append(provider)
logger.debug(
f"Initialized {provider_row.provider_type} provider",
extra={
@@ -200,6 +211,12 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
"models_cached": len(provider.get_cached_models()),
},
)
return provider
return None
tasks = [_init_single_provider(row) for row in existing_providers]
results = await asyncio.gather(*tasks)
upstreams = [p for p in results if p is not None]
return upstreams