add routstr provider

This commit is contained in:
9qeklajc
2026-02-12 11:19:40 +01:00
parent 58fa063c6b
commit c5fd386c1e
8 changed files with 562 additions and 32 deletions
+38 -10
View File
@@ -456,18 +456,18 @@ async def batch_override_provider_models(
logger.info(
f"BATCH_OVERRIDE called: provider_id={provider_id}, count={len(payload.models)}"
)
async with create_session() as session:
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
overridden_count = 0
for model_data in payload.models:
# Try to get existing model regardless of whether it's enabled or not
existing_row = await session.get(ModelRow, (model_data.id, provider_id))
if existing_row:
# Update existing
existing_row.name = model_data.name
@@ -483,7 +483,9 @@ async def batch_override_provider_models(
else None
)
existing_row.top_provider = (
json.dumps(model_data.top_provider) if model_data.top_provider else None
json.dumps(model_data.top_provider)
if model_data.top_provider
else None
)
existing_row.canonical_slug = model_data.canonical_slug
existing_row.alias_ids = (
@@ -508,23 +510,32 @@ async def batch_override_provider_models(
else None
),
top_provider=(
json.dumps(model_data.top_provider) if model_data.top_provider else None
json.dumps(model_data.top_provider)
if model_data.top_provider
else None
),
canonical_slug=model_data.canonical_slug,
alias_ids=(
json.dumps(model_data.alias_ids) if model_data.alias_ids else None
json.dumps(model_data.alias_ids)
if model_data.alias_ids
else None
),
upstream_provider_id=provider_id,
enabled=model_data.enabled,
)
session.add(row)
overridden_count += 1
await session.commit()
await refresh_model_maps()
return {"ok": True, "count": overridden_count, "message": f"Successfully batch overridden {overridden_count} models"}
return {
"ok": True,
"count": overridden_count,
"message": f"Successfully batch overridden {overridden_count} models",
}
class UpstreamProviderCreate(BaseModel):
provider_type: str
@@ -533,6 +544,7 @@ class UpstreamProviderCreate(BaseModel):
api_version: str | None = None
enabled: bool = True
provider_fee: float = 1.01
provider_settings: dict | None = None
class UpstreamProviderUpdate(BaseModel):
@@ -542,6 +554,7 @@ class UpstreamProviderUpdate(BaseModel):
api_version: str | None = None
enabled: bool | None = None
provider_fee: float | None = None
provider_settings: dict | None = None
@admin_router.get("/api/upstream-providers", dependencies=[Depends(require_admin_api)])
@@ -558,6 +571,9 @@ async def get_upstream_providers() -> list[dict[str, object]]:
"api_version": p.api_version,
"enabled": p.enabled,
"provider_fee": p.provider_fee,
"provider_settings": json.loads(p.provider_settings)
if p.provider_settings
else None,
}
for p in providers
]
@@ -585,6 +601,9 @@ async def create_upstream_provider(
api_version=payload.api_version,
enabled=payload.enabled,
provider_fee=payload.provider_fee,
provider_settings=json.dumps(payload.provider_settings)
if payload.provider_settings
else None,
)
session.add(provider)
await session.commit()
@@ -600,6 +619,7 @@ async def create_upstream_provider(
"api_version": provider.api_version,
"enabled": provider.enabled,
"provider_fee": provider.provider_fee,
"provider_settings": payload.provider_settings,
}
@@ -619,6 +639,9 @@ async def get_upstream_provider(provider_id: int) -> dict[str, object]:
"api_version": provider.api_version,
"enabled": provider.enabled,
"provider_fee": provider.provider_fee,
"provider_settings": json.loads(provider.provider_settings)
if provider.provider_settings
else None,
}
@@ -645,6 +668,8 @@ async def update_upstream_provider(
provider.enabled = payload.enabled
if payload.provider_fee is not None:
provider.provider_fee = payload.provider_fee
if payload.provider_settings is not None:
provider.provider_settings = json.dumps(payload.provider_settings)
session.add(provider)
await session.commit()
@@ -660,6 +685,9 @@ async def update_upstream_provider(
"api_version": provider.api_version,
"enabled": provider.enabled,
"provider_fee": provider.provider_fee,
"provider_settings": json.loads(provider.provider_settings)
if provider.provider_settings
else None,
}
+3
View File
@@ -146,6 +146,9 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
provider_fee: float = Field(
default=1.01, description="Provider fee multiplier (default 1%)"
)
provider_settings: str | None = Field(
default=None, description="JSON string for provider-specific settings"
)
models: list["ModelRow"] = Relationship(
back_populates="upstream_provider",
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
+50 -4
View File
@@ -19,6 +19,7 @@ class LogManager:
specific_date: str | None = None,
reverse_files: bool = False,
max_files: int | None = None,
window_center: datetime | None = None,
) -> Iterator[dict[str, Any]]:
"""
Yields log entries from files.
@@ -28,6 +29,7 @@ class LogManager:
specific_date: specific date string (YYYY-MM-DD) to look at.
reverse_files: if True, process files in reverse order (newest first).
max_files: maximum number of log files to process (most recent if reverse_files is True).
window_center: datetime object to center a 5-month window around.
"""
if not self.logs_dir.exists():
return
@@ -41,6 +43,36 @@ class LogManager:
log_files.append(log_file)
else:
log_files = sorted(self.logs_dir.glob("app_*.log"))
if window_center:
# Calculate the 5 months: [center-2, center-1, center, center+1, center+2]
allowed_month_years = []
cur_m = window_center.month
cur_y = window_center.year
for offset in range(-2, 3):
m = cur_m + offset
y = cur_y
while m <= 0:
m += 12
y -= 1
while m > 12:
m -= 12
y += 1
allowed_month_years.append(f"{y}-{m:02d}")
filtered_files = []
for log_path in log_files:
try:
# Stem is "app_YYYY-MM-DD"
file_date_str = log_path.stem.split("_")[1]
file_month_year = file_date_str[:7] # YYYY-MM
if file_month_year in allowed_month_years:
filtered_files.append(log_path)
except Exception:
continue
log_files = filtered_files
if reverse_files:
log_files.reverse()
@@ -217,11 +249,19 @@ class LogManager:
return True
def get_usage_summary(self, hours: int = 24) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
entries = list(
self._yield_log_entries(
hours_back=hours, window_center=datetime.now(timezone.utc)
)
)
return self._calculate_summary_stats(entries)
def get_usage_metrics(self, interval: int = 15, hours: int = 24) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
entries = list(
self._yield_log_entries(
hours_back=hours, window_center=datetime.now(timezone.utc)
)
)
return self._aggregate_metrics_by_time(entries, interval, hours)
def get_error_details(self, hours: int = 24, limit: int = 100) -> dict:
@@ -236,7 +276,9 @@ class LogManager:
# Let's just stick to PR 229 logic which filters 'ERROR' level.
entries = self._yield_log_entries(hours_back=hours) # oldest to newest
entries = self._yield_log_entries(
hours_back=hours, window_center=datetime.now(timezone.utc)
) # oldest to newest
for entry in entries:
if entry.get("levelname", "").upper() == "ERROR":
@@ -257,7 +299,11 @@ class LogManager:
return {"errors": errors[:limit], "total_count": len(errors)}
def get_revenue_by_model(self, hours: int = 24, limit: int = 20) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
entries = list(
self._yield_log_entries(
hours_back=hours, window_center=datetime.now(timezone.utc)
)
)
model_stats: dict[str, dict[str, int | float]] = defaultdict(
lambda: {
+2
View File
@@ -10,6 +10,7 @@ from .openai import OpenAIUpstreamProvider
from .openrouter import OpenRouterUpstreamProvider
from .perplexity import PerplexityUpstreamProvider
from .ppqai import PPQAIUpstreamProvider
from .routstr import RoutstrUpstreamProvider
from .xai import XAIUpstreamProvider
upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
@@ -24,6 +25,7 @@ upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
OpenRouterUpstreamProvider,
PerplexityUpstreamProvider,
PPQAIUpstreamProvider,
RoutstrUpstreamProvider,
XAIUpstreamProvider,
]
"""List of all upstream classes"""
+149
View File
@@ -0,0 +1,149 @@
from typing import TYPE_CHECKING, Any, Mapping
import httpx
from ..core import get_logger
from ..payment.models import Model
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
logger = get_logger(__name__)
class RoutstrUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider for communicating with another Routstr instance."""
provider_type = "routstr"
default_base_url = None
platform_url = None
def __init__(
self,
base_url: str,
api_key: str,
provider_fee: float = 1.01,
provider_settings: dict | None = None,
):
"""Initialize Routstr provider.
Args:
base_url: Base URL of the upstream Routstr instance
api_key: API key for the upstream Routstr instance
provider_fee: Provider fee multiplier
provider_settings: Provider-specific settings (auto-topup, etc.)
"""
# Ensure base_url doesn't end with /v1 as BaseUpstreamProvider appends it if needed
# but Routstr paths are usually absolute from base.
super().__init__(
base_url=base_url.rstrip("/"),
api_key=api_key,
provider_fee=provider_fee,
)
self.settings = provider_settings or {}
@classmethod
def from_db_row(
cls, provider_row: "UpstreamProviderRow"
) -> "RoutstrUpstreamProvider":
import json
settings = {}
if provider_row.provider_settings:
try:
settings = json.loads(provider_row.provider_settings)
except Exception:
pass
return cls(
base_url=provider_row.base_url,
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
provider_settings=settings,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
return {
"id": cls.provider_type,
"name": "Routstr Node",
"default_base_url": "",
"fixed_base_url": False,
"platform_url": cls.platform_url,
"can_create_account": False,
"can_topup": True,
"can_show_balance": True,
}
async def get_balance(self) -> float | None:
"""Fetch balance from the upstream Routstr node.
Returns:
Balance in satoshis, or None if failed
"""
url = f"{self.base_url}/v1/balance/info"
headers = {"Authorization": f"Bearer {self.api_key}"}
async with httpx.AsyncClient() as client:
try:
response = await client.get(url, headers=headers, timeout=10.0)
response.raise_for_status()
data = response.json()
# Routstr balance info usually contains 'balance' in msats or sats
# Check for msats and convert to sats
if "balance_msats" in data:
return float(data["balance_msats"]) / 1000.0
return float(data.get("balance", 0))
except Exception as e:
logger.error(
"Failed to fetch balance from upstream Routstr",
extra={"url": url, "error": str(e)},
)
return None
async def topup(self, cashu_token: str) -> dict[str, Any]:
"""Top up balance on the upstream Routstr node.
Args:
cashu_token: Cashu token to deposit
Returns:
Dict containing top-up result
"""
url = f"{self.base_url}/v1/balance/topup"
headers = {"Authorization": f"Bearer {self.api_key}"}
payload = {"cashu_token": cashu_token}
async with httpx.AsyncClient() as client:
try:
response = await client.post(
url, headers=headers, json=payload, timeout=30.0
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(
"Failed to topup upstream Routstr",
extra={"url": url, "error": str(e)},
)
return {"error": str(e)}
async def fetch_models(self) -> list[Model]:
"""Fetch models from the upstream Routstr node."""
url = f"{self.base_url}/v1/models"
headers = {"Authorization": f"Bearer {self.api_key}"}
async with httpx.AsyncClient() as client:
try:
response = await client.get(url, headers=headers, timeout=15.0)
response.raise_for_status()
data = response.json()
models = data.get("data", [])
return [Model(**m) for m in models]
except Exception as e:
logger.error(
"Failed to fetch models from upstream Routstr",
extra={"url": url, "error": str(e)},
)
return []
+19 -13
View File
@@ -155,24 +155,30 @@ export default function DashboardPage() {
<SelectTrigger className='w-[180px]'>
<SelectValue placeholder='Select time range' />
</SelectTrigger>
<SelectContent>
<SelectItem value='1'>Last Hour</SelectItem>
<SelectItem value='6'>Last 6 Hours</SelectItem>
<SelectItem value='24'>Last 24 Hours</SelectItem>
<SelectItem value='72'>Last 3 Days</SelectItem>
<SelectItem value='168'>Last Week</SelectItem>
</SelectContent>
<SelectContent>
<SelectItem value='1'>Last Hour</SelectItem>
<SelectItem value='6'>Last 6 Hours</SelectItem>
<SelectItem value='24'>Last 24 Hours</SelectItem>
<SelectItem value='72'>Last 3 Days</SelectItem>
<SelectItem value='168'>Last Week</SelectItem>
<SelectItem value='720'>Last Month</SelectItem>
<SelectItem value='8760'>Last Year</SelectItem>
</SelectContent>
</Select>
<Select value={interval} onValueChange={setInterval}>
<SelectTrigger className='w-[180px]'>
<SelectValue placeholder='Select interval' />
</SelectTrigger>
<SelectContent>
<SelectItem value='5'>5 Minutes</SelectItem>
<SelectItem value='15'>15 Minutes</SelectItem>
<SelectItem value='30'>30 Minutes</SelectItem>
<SelectItem value='60'>1 Hour</SelectItem>
</SelectContent>
<SelectContent>
<SelectItem value='5'>5 Minutes</SelectItem>
<SelectItem value='15'>15 Minutes</SelectItem>
<SelectItem value='30'>30 Minutes</SelectItem>
<SelectItem value='60'>1 Hour</SelectItem>
<SelectItem value='1440'>1 Day</SelectItem>
<SelectItem value='10080'>1 Week</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleRefresh} variant='outline' size='icon'>
<RefreshCw className='h-4 w-4' />
+298 -5
View File
@@ -417,6 +417,7 @@ export default function ProvidersPage() {
api_version: null,
enabled: true,
provider_fee: 1.06,
provider_settings: {},
});
const getProviderFeePlaceholder = (type: string) => {
@@ -560,6 +561,7 @@ export default function ProvidersPage() {
api_version: provider.api_version || null,
enabled: provider.enabled,
provider_fee: provider.provider_fee,
provider_settings: provider.provider_settings || {},
});
setIsEditDialogOpen(true);
};
@@ -572,6 +574,7 @@ export default function ProvidersPage() {
api_version: formData.api_version,
enabled: formData.enabled,
provider_fee: formData.provider_fee,
provider_settings: formData.provider_settings,
};
if (formData.api_key) {
updateData.api_key = formData.api_key;
@@ -721,6 +724,152 @@ export default function ProvidersPage() {
</SelectContent>
</Select>
</div>
{formData.provider_type === 'routstr' && (
<div className='bg-muted/30 grid gap-4 rounded-lg border p-4'>
<div className='flex items-center justify-between'>
<Label className='text-sm font-semibold'>
Routstr Node Settings
</Label>
<Badge variant='outline' className='text-[10px]'>
JSONB Storage
</Badge>
</div>
<div className='grid gap-3'>
<div className='flex items-center justify-between'>
<Label htmlFor='auto_topup' className='text-sm'>
Enable Auto Top-up
</Label>
<Switch
id='auto_topup'
checked={!!formData.provider_settings?.auto_topup}
onCheckedChange={(checked) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
auto_topup: checked,
},
})
}
/>
</div>
{formData.provider_settings?.auto_topup && (
<div className='border-primary/20 grid gap-4 border-l-2 pt-2 pl-4'>
<div className='grid gap-2'>
<Label
htmlFor='topup_threshold'
className='text-xs font-medium'
>
When credits are below (Sats)
</Label>
<Input
id='topup_threshold'
type='number'
size='sm'
className='h-9'
placeholder='e.g. 1000'
value={
formData.provider_settings
?.topup_threshold || ''
}
onChange={(e) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
topup_threshold: parseInt(
e.target.value
),
},
})
}
/>
</div>
<div className='grid gap-2'>
<Label
htmlFor='topup_amount_limit'
className='text-xs font-medium'
>
Purchase this amount (Sats)
</Label>
<Input
id='topup_amount_limit'
type='number'
size='sm'
className='h-9'
placeholder='e.g. 5000'
value={
formData.provider_settings
?.topup_amount_limit || ''
}
onChange={(e) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
topup_amount_limit: parseInt(
e.target.value
),
},
})
}
/>
</div>
</div>
)}
<div className='flex items-center justify-between'>
<Label
htmlFor='refund_on_expiry'
className='text-xs'
>
Auto-Refund Expired Keys
</Label>
<Switch
id='refund_on_expiry'
checked={
!!formData.provider_settings?.refund_on_expiry
}
onCheckedChange={(checked) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
refund_on_expiry: checked,
},
})
}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='refund_address' className='text-xs'>
Global Refund Address (LNURL/Address)
</Label>
<Input
id='refund_address'
className='h-8 text-xs'
placeholder='lightning@address.com'
value={
formData.provider_settings?.refund_address || ''
}
onChange={(e) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
refund_address: e.target.value,
},
})
}
/>
</div>
</div>
</div>
)}
<div className='grid gap-2'>
<Label htmlFor='base_url'>Base URL</Label>
<Input
@@ -903,12 +1052,19 @@ export default function ProvidersPage() {
<div className='flex flex-wrap items-center gap-2'>
{canShowBalance(provider.provider_type) &&
provider.api_key && (
<ProviderBalance
providerId={provider.id}
platformUrl={getPlatformUrl(
provider.provider_type
<div className='flex flex-col gap-1'>
<ProviderBalance
providerId={provider.id}
platformUrl={getPlatformUrl(
provider.provider_type
)}
/>
{provider.provider_type === 'routstr' && (
<div className='text-muted-foreground font-mono text-[10px]'>
sk-{provider.api_key.substring(0, 8)}...
</div>
)}
/>
</div>
)}
<Button
variant='outline'
@@ -1219,6 +1375,143 @@ export default function ProvidersPage() {
</SelectContent>
</Select>
</div>
{formData.provider_type === 'routstr' && (
<div className='bg-muted/30 grid gap-4 rounded-lg border p-4'>
<div className='flex items-center justify-between'>
<Label className='text-sm font-semibold'>
Routstr Node Settings
</Label>
<Badge variant='outline' className='text-[10px]'>
JSONB Storage
</Badge>
</div>
<div className='grid gap-3'>
<div className='flex items-center justify-between'>
<Label htmlFor='edit_auto_topup' className='text-sm'>
Enable Auto Top-up
</Label>
<Switch
id='edit_auto_topup'
checked={!!formData.provider_settings?.auto_topup}
onCheckedChange={(checked) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
auto_topup: checked,
},
})
}
/>
</div>
{formData.provider_settings?.auto_topup && (
<div className='border-primary/20 grid gap-4 border-l-2 pt-2 pl-4'>
<div className='grid gap-2'>
<Label
htmlFor='edit_topup_threshold'
className='text-xs font-medium'
>
When credits are below (Sats)
</Label>
<Input
id='edit_topup_threshold'
type='number'
size='sm'
className='h-9'
placeholder='e.g. 1000'
value={
formData.provider_settings?.topup_threshold || ''
}
onChange={(e) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
topup_threshold: parseInt(e.target.value),
},
})
}
/>
</div>
<div className='grid gap-2'>
<Label
htmlFor='edit_topup_amount_limit'
className='text-xs font-medium'
>
Purchase this amount (Sats)
</Label>
<Input
id='edit_topup_amount_limit'
type='number'
size='sm'
className='h-9'
placeholder='e.g. 5000'
value={
formData.provider_settings?.topup_amount_limit ||
''
}
onChange={(e) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
topup_amount_limit: parseInt(e.target.value),
},
})
}
/>
</div>
</div>
)}
<div className='flex items-center justify-between'>
<Label
htmlFor='edit_refund_on_expiry'
className='text-xs'
>
Auto-Refund Expired Keys
</Label>
<Switch
id='edit_refund_on_expiry'
checked={!!formData.provider_settings?.refund_on_expiry}
onCheckedChange={(checked) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
refund_on_expiry: checked,
},
})
}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='edit_refund_address' className='text-xs'>
Global Refund Address (LNURL/Address)
</Label>
<Input
id='edit_refund_address'
className='h-8 text-xs'
placeholder='lightning@address.com'
value={formData.provider_settings?.refund_address || ''}
onChange={(e) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
refund_address: e.target.value,
},
})
}
/>
</div>
</div>
</div>
)}
<div className='grid gap-2'>
<Label htmlFor='edit_base_url'>Base URL</Label>
<Input
+3
View File
@@ -20,6 +20,7 @@ export const UpstreamProviderSchema = z.object({
api_version: z.string().nullable().optional(),
enabled: z.boolean(),
provider_fee: z.number().optional(),
provider_settings: z.record(z.any()).nullable().optional(),
});
export const CreateUpstreamProviderSchema = z.object({
@@ -29,6 +30,7 @@ export const CreateUpstreamProviderSchema = z.object({
api_version: z.string().nullable().optional(),
enabled: z.boolean().default(true),
provider_fee: z.number().optional(),
provider_settings: z.record(z.any()).nullable().optional(),
});
export const UpdateUpstreamProviderSchema = z.object({
@@ -38,6 +40,7 @@ export const UpdateUpstreamProviderSchema = z.object({
api_version: z.string().nullable().optional(),
enabled: z.boolean().optional(),
provider_fee: z.number().optional(),
provider_settings: z.record(z.any()).nullable().optional(),
});
export const AdminModelPricingSchema = z.object({