Compare commits

..
Author SHA1 Message Date
9qeklajc b88e0d6fd0 usage calculation 2025-11-12 23:39:35 +01:00
shroominicandGitHub 2da2f96118 Merge pull request #218 from Routstr/v0.2.0-final
add docs, fix anthropic upstream model alias problem
2025-11-11 13:48:05 +08:00
Shroominic 8cb73f4528 fix import error 2025-11-11 13:40:59 +08:00
Shroominic e3b146b83f Merge branch 'upstream-refactor' into v0.2.0 2025-11-11 13:09:02 +08:00
Shroominic b7e4fbf739 fix model alias problem for anthropic 2025-11-11 12:43:02 +08:00
Shroominic 2c8ba93312 experiments 2025-11-11 10:24:34 +08:00
9qeklajc cfe03d6dcb remove redundant setting & doc 2025-11-10 22:59:32 +01:00
9qeklajc 80b6acbf4b add docs 2025-11-10 22:40:35 +01:00
9qeklajcandGitHub bd88a84cd4 Merge pull request #216 from Routstr/v0.2.0-final
update doc
2025-11-09 21:37:11 +01:00
9qeklajc 45f5ba96a8 update doc 2025-11-09 21:36:42 +01:00
Shroominic 4b935a6f4d more upstream + model fetchin wip 2025-11-08 12:30:12 +08:00
Shroominic c9f458b8ba experimentation to get better fetching algorighm 2025-11-06 18:27:17 +08:00
Shroominic d9d2e17e5d refactor upstream files and classes 2025-11-06 17:23:37 +08:00
9qeklajcandGitHub 3d6bd65a64 Merge pull request #215 from Routstr/v0.2.0-final
* fix model filtering
* cleanup desing
2025-11-05 09:35:10 +01:00
9qeklajc 8c08be9e11 better ui 2025-11-05 00:12:23 +01:00
9qeklajc c49da9bf84 ignore error 2025-11-04 23:50:55 +01:00
9qeklajc c2b97f8e3b use enabled flag 2025-11-04 23:48:38 +01:00
9qeklajc 74480df47d different check 2025-11-04 23:48:24 +01:00
9qeklajc f5c9cde852 clean up 2025-11-04 23:46:38 +01:00
9qeklajc 88fbefbd18 fmt 2025-11-04 23:29:07 +01:00
9qeklajc ded82cd729 fix model endpoint & filter query 2025-11-04 23:16:08 +01:00
9qeklajc 1e90b223cf fix model filter 2025-11-04 22:45:18 +01:00
9qeklajc b8a1d69924 fmt 2025-11-04 22:45:07 +01:00
9qeklajc 33b19ba98b better model naming 2025-11-04 22:28:48 +01:00
34 changed files with 873 additions and 613 deletions
+1
View File
@@ -99,6 +99,7 @@ The most common settings are shown below. See `.env.example` for the full list.
- `NPUB` Nostr public key of the proxy
- `HTTP_URL` Public-facing URL of the proxy
- `ONION_URL` Tor hidden service URL of the proxy
- `NEXT_PUBLIC_API_URL` - UI Configuration for Next.js frontend (proxy URL, default: 'http://127.0.0.1:8000' )
## Database Migrations
+53
View File
@@ -0,0 +1,53 @@
# UI Configuration
This guide explains how to configure the Routstr UI for different environments.
## Environment Variables
The UI uses Next.js environment variables to configure API endpoints and authentication.
### Centralized Configuration
This project uses a centralized configuration approach with a single `.env` file in the project root. This file contains both backend and frontend configuration variables.
Create or update your `.env` file in the project root:
```bash
# .env (in project root)
# UI Configuration (NEXT_PUBLIC_ variables are exposed to the browser)
NEXT_PUBLIC_API_URL=http://127.0.0.1:8000
```
### Development vs Production
The same `.env` file is used for both development and production. Simply change the values:
**Development:**
```bash
NEXT_PUBLIC_API_URL=http://127.0.0.1:8000
```
**Production:**
```bash
NEXT_PUBLIC_API_URL=https://api.yourroutstr.com
```
## Building the UI
The build process automatically reads configuration from the root `.env` file:
```bash
# From the project root
make ui-build
# or
./scripts/build-ui.sh
```
The build script will automatically:
- Load `NEXT_PUBLIC_*` variables from the root `.env` file
- Use them during the Next.js build process
- Display warnings if the `.env` file is missing
+137 -250
View File
@@ -1,329 +1,216 @@
# Admin Dashboard
The Routstr admin dashboard provides a web interface for managing your node, viewing balances, and handling withdrawals.
The Routstr admin dashboard is a modern web interface for managing your node, monitoring wallet balances, configuring AI models and providers, and handling Bitcoin Lightning payments through Cashu eCash.
## Accessing the Dashboard
### URL Format
The admin dashboard is available at:
```
https://api.routstr.com/admin/
```
> **Important**: Always include the trailing slash (`/`) in the URL.
### Authentication
The dashboard is protected by a password set in the `ADMIN_PASSWORD` environment variable.
The dashboard is protected by password authentication:
1. Navigate to `/admin/`
1. Navigate to `/admin/` in your browser
2. Enter the admin password
3. Click "Login"
3. Optional: Configure custom base URL if not pre-configured
4. Click "Login"
The password is stored as a secure cookie for the session.
The interface supports both environment-configured URLs and manual URL entry for deployment flexibility.
## Dashboard Overview
### Main Interface
The main dashboard consists of four primary sections accessible through a collapsible sidebar:
The dashboard displays:
- **Dashboard** - Wallet balance monitoring and fund management
- **Models** - AI model management and testing
- **Providers** - Upstream provider configuration
- **Settings** - Node configuration and admin preferences
- **Node Information**
- Node name and description
- Version number
- Public URLs (HTTP and Onion)
- Supported Cashu mints
### Navigation
- **Statistics**
- Total API keys
- Active keys
- Total balance across all keys
- Recent activity
## Dashboard Page
- **API Key List**
- All keys with balances
- Usage statistics
- Management options
### Wallet Balance Management
## Features
#### Balance Display Options
### Viewing API Keys
Switch between display units using the toggle buttons:
The main table shows all API keys with:
- **msat** - Millisatoshis (highest precision)
- **sat** - Satoshis (standard Bitcoin unit)
- **usd** - US Dollar equivalent (when exchange rate available)
| Column | Description |
|--------|-------------|
| API Key | Masked key (first/last 4 chars) |
| Balance | Current balance in sats |
| Created | Creation timestamp |
| Last Used | Most recent API call |
| Total Spent | Lifetime usage |
| Status | Active/Expired/Disabled |
#### Balance Overview
### Searching and Filtering
The dashboard displays three key metrics:
- **Search**: Find keys by partial match
- **Sort**: Click column headers to sort
- **Filter**: Show only active/expired keys
- **Export**: Download data as CSV
- **Your Balance (Total)** - Available funds for node operator
- **Total Wallet** - Combined balance across all Cashu mints
- **User Balance** - Funds held for API key holders
### Key Details
#### Detailed Balance Breakdown
Click on any key to view:
View balances by mint with the following information:
- Full API key (masked by default)
- Complete transaction history
- Usage graphs
- Metadata (name, expiry, refund address)
| Column | Description |
| ----------- | ------------------------------------- |
| Mint / Unit | Cashu mint URL and currency unit |
| Wallet | Total funds in this mint |
| Users | Funds belonging to API key holders |
| Owner | Your available funds (Wallet - Users) |
## Balance Management
### Temporary Balances
### Viewing Balances
Monitor API key activity with:
Balances are displayed in multiple units:
- **Summary Cards** - Total balance, total spent, total requests
- **Search Functionality** - Filter by key hash or refund address
- **Detailed Table** - Individual key balances with expiry times
- **Auto-refresh** - Updates every 60 seconds
- **Sats**: Standard satoshi units
- **mSats**: Millisatoshis (internal precision)
- **BTC**: Bitcoin decimal format
- **USD**: Approximate USD value
### Fund Management
### Balance History
#### Withdrawing Funds
View balance changes over time:
To withdraw your available balance:
```
Time | Type | Amount | Balance | Description
-------------|-----------|---------|---------|-------------
12:34:56 | Deposit | +10,000 | 10,000 | Token redemption
12:35:12 | Usage | -154 | 9,846 | gpt-3.5-turbo call
12:36:45 | Usage | -210 | 9,636 | gpt-4 call
```
1. Click the **Withdraw** button
2. Select which mint to withdraw from
3. Specify the amount (or withdraw full balance)
4. Click **Generate Token**
5. Copy the generated eCash token
6. Import the token into your Cashu wallet
## Withdrawals
#### Real-time Updates
### Manual Withdrawal
- Balances refresh automatically every 30 seconds
- Manual refresh option available
- Live Bitcoin/USD exchange rate integration
- Error handling for mint connectivity issues
To withdraw funds from an API key:
## Models Management Page
1. Click "Withdraw" next to the key
2. Optionally specify amount (default: full balance)
3. Select target Cashu mint
4. Click "Generate Token"
5. Copy the eCash token
6. Redeem in your Cashu wallet
### Model Organization
### Bulk Operations
Models are organized by provider groups with tabs:
For multiple withdrawals:
- **All Models** - Combined view of all available models
- **Provider-specific tabs** - Individual providers (OpenRouter, Azure, etc.)
- Badge indicators showing active/total model counts
1. Select keys using checkboxes
2. Click "Bulk Actions" → "Withdraw"
3. Tokens are generated for each key
4. Download all tokens as text file
### Model Management Features
### Automatic Withdrawals
#### Individual Model Operations
If configured with `RECEIVE_LN_ADDRESS`:
For each model you can:
- Balances above threshold auto-convert to Lightning
- Sent to configured Lightning address
- View payout history in dashboard
- **Toggle Enable/Disable** - Control model availability
- **View Details** - Context length, pricing, description
- **Edit Configuration** - Model-specific settings
- **Status Indicators** - Green badges for enabled, gray for disabled
## Node Configuration
#### Bulk Operations
### Viewing Settings
- **Select All/Deselect All** - Quick selection controls
- **Bulk Enable/Disable** - Mass model management
- **Bulk Delete** - Remove model overrides
- **Provider-level Actions** - Apply settings to all models in a provider
Current node configuration is displayed:
#### Model Information Display
- Upstream provider URL
- Enabled features
- Pricing model
- Fee structure
- **Model Types** - Text, embedding, image, audio, multimodal indicators
- **Pricing Information** - Per-million-token costs for input/output
- **Context Length** - Maximum tokens supported
- **API Key Status** - Whether credentials are configured
- **Free Model Indicators** - No-cost models clearly marked
### Models and Pricing
## Providers Management Page
View supported models and their pricing:
### Upstream Provider Configuration
| Model | Input $/1K | Output $/1K | Sats/1K |
|-------|------------|-------------|---------|
| gpt-3.5-turbo | $0.0015 | $0.002 | 3/4 |
| gpt-4 | $0.03 | $0.06 | 60/120 |
| dall-e-3 | - | - | 1000/image |
Manage AI provider connections and credentials:
### Updating Configuration
#### Provider Types Supported
> **Note**: Configuration changes require node restart.
- **OpenRouter** - Multi-model aggregator
- **Azure OpenAI** - Microsoft's OpenAI service
- **OpenAI** - Direct OpenAI integration
- **Custom Providers** - Any OpenAI-compatible API
To update settings:
#### Adding New Providers
1. Modify environment variables
2. Restart the node
3. Verify changes in dashboard
1. Click **Add Provider**
2. Select **Provider Type** from dropdown
3. Enter **Base URL** (auto-populated for known providers)
4. Add **API Key** for authentication
5. Set **API Version** (required for Azure)
6. Toggle **Enabled** status
7. Click **Create**
## Analytics
#### Provider Management
### Usage Statistics
**Provider Cards Display:**
View comprehensive usage data:
- Provider type and status (Enabled/Disabled)
- Base URL configuration
- Action buttons (Models, Edit, Delete)
- **Requests per Day**: Line graph
- **Token Usage**: Stacked bar chart
- **Model Distribution**: Pie chart
- **Cost Analysis**: Breakdown by model
**Available Actions:**
### Performance Metrics
- **Edit** - Modify provider configuration
- **Delete** - Remove provider (with confirmation)
- **View Models** - Expand model discovery interface
- **Enable/Disable** - Toggle provider availability
Monitor node performance:
#### Model Discovery
- Average response time
- Request success rate
- Upstream API latency
- Cache hit ratio
Each provider shows two types of models:
### Export Data
**Provided Models Tab:**
Export analytics data:
- Auto-discovered from provider's catalog
- Read-only model information
- Real-time availability updates
1. Select date range
2. Choose metrics
3. Click "Export"
4. Download as CSV/JSON
**Custom Models Tab:**
## Security Features
- Manually configured model overrides
- Extend or override provider catalog
- Individual enable/disable controls
### Access Control
## Settings Page
- Password protection
- Session timeout (configurable)
- IP allowlisting (optional)
- Audit logging
### Node Configuration
### Security Log
Configure core node settings and preferences:
View security events:
#### Basic Information
```
2024-01-15 12:34:56 | Login Success | IP: 192.168.1.1
2024-01-15 12:35:12 | Withdrawal | Key: sk-****abcd | Amount: 5000
2024-01-15 12:40:00 | Session Timeout | IP: 192.168.1.1
```
- **Node Name** - Identifier for your node
- **Node Description** - Descriptive text for your service
- **HTTP URL** - Public HTTP endpoint
- **Onion URL** - Tor hidden service address
### Best Practices
#### Nostr Integration
1. **Strong Password**: Use a long, random password
2. **HTTPS Only**: Always access via HTTPS
3. **Regular Monitoring**: Check logs frequently
4. **Limited Access**: Restrict dashboard access
- **Public Key (npub)** - Your Nostr public identity
- **Private Key (nsec)** - Nostr private key with show/hide toggle
- **Nostr Relays** - Configure relays for provider announcements
## Troubleshooting
#### Cashu Mint Management
### Cannot Access Dashboard
- **Add Mint URLs** - Configure multiple Cashu mint endpoints
- **Remove Mints** - Delete unused mint configurations
- **Mint Validation** - Verify mint endpoint connectivity
**Issue**: 404 Not Found
#### Settings Features
- Ensure trailing slash: `/admin/`
- Check if admin routes are enabled
**Issue**: Unauthorized
- Verify `ADMIN_PASSWORD` is set
- Clear browser cookies
- Try incognito/private mode
### Display Issues
**Issue**: Broken Layout
- Clear browser cache
- Disable ad blockers
- Try different browser
**Issue**: Missing Data
- Check database connectivity
- Verify node is running
- Review error logs
### Withdrawal Problems
**Issue**: Token Generation Fails
- Check mint connectivity
- Verify sufficient balance
- Try different mint
**Issue**: Invalid Token
- Ensure complete token copy
- Check token hasn't expired
- Verify mint compatibility
## Advanced Features
### Custom Branding
Customize dashboard appearance:
```bash
# Environment variables
ADMIN_LOGO_URL=https://example.com/logo.png
ADMIN_THEME_COLOR=#FF6B00
ADMIN_CUSTOM_CSS=/path/to/custom.css
```
### API Access
Access admin functions programmatically:
```bash
# Get node stats
curl -X GET https://your-node.com/admin/api/stats \
-H "X-Admin-Password: your-password"
# Export key data
curl -X GET https://your-node.com/admin/api/keys \
-H "X-Admin-Password: your-password" \
-H "Accept: application/json"
```
### Webhooks
Configure notifications:
```bash
ADMIN_WEBHOOK_URL=https://example.com/webhook
ADMIN_WEBHOOK_EVENTS=withdrawal,low_balance,error
```
## Dashboard Shortcuts
### Keyboard Navigation
- `Ctrl+K`: Quick search
- `Ctrl+R`: Refresh data
- `Ctrl+E`: Export current view
- `Escape`: Close modals
### Quick Actions
- Double-click to copy API key
- Right-click for context menu
- Drag to reorder columns
- Shift-click to select multiple
## Mobile Access
The dashboard is mobile-responsive:
- Touch-optimized controls
- Swipe navigation
- Compact view mode
- Offline capability
- **Real-time Save** - Changes apply immediately
- **Validation** - Form validation with error feedback
- **Secure Fields** - Password masking with reveal toggles
- **Reload Functionality** - Refresh configuration from server
## Next Steps
- [Models & Pricing](models-pricing.md) - Configure pricing
- [API Reference](../api/overview.md) - Admin API endpoints
- [Advanced Configuration](../advanced/custom-pricing.md) - Advanced settings
- [Payment Flow](payment-flow.md) - Understanding Bitcoin payment processing
- [Using the API](using-api.md) - Making API requests to your node
- [Models & Pricing](models-pricing.md) - Configuring model pricing and fees
- [API Reference](../api/overview.md) - Complete API documentation
+16 -12
View File
@@ -6,7 +6,7 @@ from .core.logging import get_logger
if TYPE_CHECKING:
from .payment.models import Model
from .upstream import UpstreamProvider
from .upstream import BaseUpstreamProvider
logger = get_logger(__name__)
@@ -59,7 +59,7 @@ def calculate_model_cost_score(model: "Model") -> float:
return total_cost
def get_provider_penalty(provider: "UpstreamProvider") -> float:
def get_provider_penalty(provider: "BaseUpstreamProvider") -> float:
"""Calculate a penalty multiplier for certain providers.
This allows applying policy-based adjustments beyond pure cost.
@@ -86,9 +86,9 @@ def get_provider_penalty(provider: "UpstreamProvider") -> float:
def should_prefer_model(
candidate_model: "Model",
candidate_provider: "UpstreamProvider",
candidate_provider: "BaseUpstreamProvider",
current_model: "Model",
current_provider: "UpstreamProvider",
current_provider: "BaseUpstreamProvider",
alias: str,
) -> bool:
"""Determine if candidate model should replace current model for an alias.
@@ -166,10 +166,10 @@ def should_prefer_model(
def create_model_mappings(
upstreams: list["UpstreamProvider"],
upstreams: list["BaseUpstreamProvider"],
overrides_by_id: dict[str, tuple],
disabled_model_ids: set[str],
) -> tuple[dict[str, "Model"], dict[str, "UpstreamProvider"], dict[str, "Model"]]:
) -> tuple[dict[str, "Model"], dict[str, "BaseUpstreamProvider"], dict[str, "Model"]]:
"""Create optimal model mappings based on cost and provider preferences.
This is the main entry point for the algorithm. It processes all upstream providers
@@ -196,12 +196,12 @@ def create_model_mappings(
from .upstream import resolve_model_alias
model_instances: dict[str, "Model"] = {}
provider_map: dict[str, "UpstreamProvider"] = {}
provider_map: dict[str, "BaseUpstreamProvider"] = {}
unique_models: dict[str, "Model"] = {}
# Separate OpenRouter from other providers
openrouter: "UpstreamProvider" | None = None
other_upstreams: list["UpstreamProvider"] = []
openrouter: "BaseUpstreamProvider" | None = None
other_upstreams: list["BaseUpstreamProvider"] = []
for upstream in upstreams:
base_url = getattr(upstream, "base_url", "")
@@ -215,7 +215,7 @@ def create_model_mappings(
return model_id.split("/", 1)[1] if "/" in model_id else model_id
def _maybe_set_alias(
alias: str, model: "Model", provider: "UpstreamProvider"
alias: str, model: "Model", provider: "BaseUpstreamProvider"
) -> None:
"""Set alias to model/provider if not set or if new model is preferred."""
existing_model = model_instances.get(alias)
@@ -233,7 +233,7 @@ def create_model_mappings(
provider_map[alias] = provider
def process_provider_models(
upstream: "UpstreamProvider", is_openrouter: bool = False
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
) -> None:
"""Process all models from a given provider."""
upstream_prefix = getattr(upstream, "upstream_name", None)
@@ -258,7 +258,11 @@ def create_model_mappings(
unique_models[base_id] = unique_model
# Get all aliases for this model
aliases = resolve_model_alias(model_to_use.id, model_to_use.canonical_slug)
aliases = resolve_model_alias(
model_to_use.id,
model_to_use.canonical_slug,
alias_ids=model_to_use.alias_ids,
)
# Add prefixed alias if applicable
if upstream_prefix and "/" not in model_to_use.id:
+180 -10
View File
@@ -1,4 +1,7 @@
import base64
import math
import struct
from typing import Optional
from pydantic.v1 import BaseModel
@@ -25,8 +28,163 @@ class CostDataError(BaseModel):
code: str
async def calculate_cost(
response_data: dict, max_cost: int, session: AsyncSession
def get_image_resolution_from_data_url(data_url: str) -> Optional[tuple[int, int]]:
"""
Extract image resolution (width, height) from a base64 data URL without DOM.
Supports PNG and JPEG. Returns None if format unsupported or parsing fails.
"""
try:
if not isinstance(data_url, str) or not data_url.startswith("data:"):
return None
comma_idx = data_url.find(",")
if comma_idx == -1:
return None
meta = data_url[5:comma_idx] # e.g. "image/png;base64"
base64_data = data_url[comma_idx + 1:]
# Decode base64 to binary
try:
binary_data = base64.b64decode(base64_data)
except Exception:
return None
is_png = "image/png" in meta
is_jpeg = "image/jpeg" in meta or "image/jpg" in meta
# PNG: width/height are 4-byte big-endian at offsets 16 and 20
if is_png:
# Validate PNG signature
png_sig = b'\x89PNG\r\n\x1a\n'
if not binary_data.startswith(png_sig):
return None
if len(binary_data) < 24:
return None
# Width and height are at bytes 16-19 and 20-23 respectively
width = struct.unpack('>I', binary_data[16:20])[0]
height = struct.unpack('>I', binary_data[20:24])[0]
if width > 0 and height > 0:
return (width, height)
return None
# JPEG: parse markers to SOF0/SOF2 for dimensions
if is_jpeg:
offset = 0
# JPEG SOI 0xFFD8
if len(binary_data) < 2 or binary_data[0] != 0xFF or binary_data[1] != 0xD8:
return None
offset = 2
while offset < len(binary_data):
# Find marker
while offset < len(binary_data) and binary_data[offset] != 0xFF:
offset += 1
if offset + 1 >= len(binary_data):
break
# Skip fill bytes 0xFF
while offset < len(binary_data) and binary_data[offset] == 0xFF:
offset += 1
if offset >= len(binary_data):
break
marker = binary_data[offset]
offset += 1
# Standalone markers without length
if marker == 0xD8 or marker == 0xD9: # SOI/EOI
continue
if offset + 1 >= len(binary_data):
break
length = (binary_data[offset] << 8) | binary_data[offset + 1]
offset += 2
# SOF0 (0xC0) or SOF2 (0xC2) contain dimensions
if marker == 0xC0 or marker == 0xC2:
if length < 7 or offset + length - 2 > len(binary_data):
return None
# Skip precision byte
height = (binary_data[offset + 1] << 8) | binary_data[offset + 2]
width = (binary_data[offset + 3] << 8) | binary_data[offset + 4]
if width > 0 and height > 0:
return (width, height)
return None
else:
# Skip this segment
offset += length - 2
return None
# Unsupported formats (e.g., webp/gif) - skip for now
return None
except Exception:
return None
def calculate_image_tokens_from_messages(messages: list) -> int:
"""
Calculate image tokens from messages using 32px patch method.
"""
image_tokens = 0
try:
for msg in messages:
content = msg.get("content")
if isinstance(content, list):
for part in content:
if (isinstance(part, dict) and
part.get("type") == "image_url"):
image_url = part.get("image_url")
url: Optional[str] = None
if isinstance(image_url, str):
url = image_url
elif isinstance(image_url, dict):
url = image_url.get("url")
else:
continue
# Expecting a base64 data URL for local image inputs
if url and isinstance(url, str) and url.startswith("data:"):
resolution = get_image_resolution_from_data_url(url)
if resolution:
width, height = resolution
patch_size = 32
patches_w = (width + patch_size - 1) // patch_size
patches_h = (height + patch_size - 1) // patch_size
tokens_from_image = patches_w * patches_h
image_tokens += tokens_from_image
logger.debug(
"Calculated image tokens",
extra={
"width": width,
"height": height,
"tokens_from_image": tokens_from_image,
}
)
else:
logger.warning(
"Could not determine image resolution",
extra={"url_prefix": url[:50] + "..." if len(url) > 50 else url}
)
except Exception as e:
logger.error(
"Error calculating image tokens",
extra={"error": str(e), "error_type": type(e).__name__}
)
return image_tokens
async def calculate_cost( # todo: can be sync
response_data: dict, max_cost: int, session: AsyncSession, request_data: Optional[dict] = None
) -> CostData | MaxCostData | CostDataError:
"""
Calculate the cost of an API request based on token usage.
@@ -34,6 +192,8 @@ async def calculate_cost(
Args:
response_data: Response data containing usage information
max_cost: Maximum cost in millisats
session: Database session
request_data: Original request data containing messages (for image token calculation)
Returns:
Cost data or error information
@@ -78,13 +238,9 @@ async def calculate_cost(
extra={"model": response_model},
)
from ..proxy import get_upstreams
from ..upstream import get_model_with_override
from ..proxy import get_model_instance
upstreams = get_upstreams()
model_obj = await get_model_with_override(
response_model, upstreams, session=session
)
model_obj = get_model_instance(response_model)
if not model_obj:
logger.error(
@@ -136,14 +292,28 @@ async def calculate_cost(
input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0)
output_tokens = response_data.get("usage", {}).get("completion_tokens", 0)
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
# Calculate image tokens from request data if available
image_tokens = 0
if request_data and "messages" in request_data:
image_tokens = calculate_image_tokens_from_messages(request_data["messages"])
logger.debug(
"Image tokens calculated",
extra={"image_tokens": image_tokens, "text_input_tokens": input_tokens}
)
# Add image tokens to input tokens for cost calculation
total_input_tokens = input_tokens + image_tokens
input_msats = round(total_input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
token_based_cost = math.ceil(input_msats + output_msats)
logger.info(
"Calculated token-based cost",
extra={
"input_tokens": input_tokens,
"text_input_tokens": input_tokens,
"image_tokens": image_tokens,
"total_input_tokens": total_input_tokens,
"output_tokens": output_tokens,
"input_cost_msats": input_msats,
"output_cost_msats": output_msats,
+2 -4
View File
@@ -104,11 +104,9 @@ async def get_max_cost_for_model(
return max(settings.min_request_msat, default_cost_msats)
if not model_obj:
from ..proxy import get_upstreams
from ..upstream import get_model_with_override
from ..proxy import get_model_instance
upstreams = get_upstreams()
model_obj = await get_model_with_override(model, upstreams, session)
model_obj = get_model_instance(model)
if not model_obj:
fallback_msats = settings.fixed_cost_per_request * 1000
+5 -5
View File
@@ -60,6 +60,7 @@ class Model(BaseModel):
enabled: bool = True
upstream_provider_id: int | None = None
canonical_slug: str | None = None
alias_ids: list[str] | None = None
def __hash__(self) -> int:
return hash(self.id)
@@ -409,6 +410,7 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
enabled=model.enabled,
upstream_provider_id=model.upstream_provider_id,
canonical_slug=model.canonical_slug,
alias_ids=model.alias_ids,
)
except Exception as e:
logger.error(
@@ -577,7 +579,6 @@ async def _cleanup_enabled_models_once() -> None:
for db_model in db_models:
# Find corresponding upstream model
print(db_model.id)
upstream_model = None
for upstream in upstreams:
upstream_model = upstream.get_cached_model_by_id(db_model.id)
@@ -613,7 +614,7 @@ async def _cleanup_enabled_models_once() -> None:
def _pricing_matches(
db_pricing: dict, upstream_pricing: dict, tolerance: float = 0.1
db_pricing: dict, upstream_pricing: dict, tolerance: float = 0.0
) -> bool:
"""Check if pricing dictionaries match within tolerance."""
keys_to_compare = [
@@ -626,9 +627,8 @@ def _pricing_matches(
]
for key in keys_to_compare:
db_val = float(db_pricing.get(key, 0.0)) * 1000000
upstream_val = float(upstream_pricing.get(key, 0.0)) * 1000000
print(db_val - upstream_val)
db_val = int(float(db_pricing.get(key, 0.0)) * 1000000)
upstream_val = int(float(upstream_pricing.get(key, 0.0)) * 1000000)
if abs(db_val - upstream_val) > tolerance:
return False
+8 -10
View File
@@ -3,7 +3,7 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from sqlmodel import col, select
from sqlmodel import select
from .algorithm import create_model_mappings
from .auth import pay_for_request, revert_pay_for_request, validate_bearer_key
@@ -23,14 +23,14 @@ from .payment.helpers import (
get_max_cost_for_model,
)
from .payment.models import Model
from .upstream import UpstreamProvider, init_upstreams
from .upstream import BaseUpstreamProvider, init_upstreams
logger = get_logger(__name__)
proxy_router = APIRouter()
_upstreams: list[UpstreamProvider] = []
_upstreams: list[BaseUpstreamProvider] = []
_model_instances: dict[str, Model] = {} # All aliases -> Model
_provider_map: dict[str, UpstreamProvider] = {} # All aliases -> Provider
_provider_map: dict[str, BaseUpstreamProvider] = {} # All aliases -> Provider
_unique_models: dict[str, Model] = {} # Unique model.id -> Model (no duplicates)
@@ -53,7 +53,7 @@ async def reinitialize_upstreams() -> None:
await refresh_model_maps()
def get_upstreams() -> list[UpstreamProvider]:
def get_upstreams() -> list[BaseUpstreamProvider]:
"""Get the initialized upstream providers.
Returns:
@@ -67,7 +67,7 @@ def get_model_instance(model_id: str) -> Model | None:
return _model_instances.get(model_id)
def get_provider_for_model(model_id: str) -> UpstreamProvider | None:
def get_provider_for_model(model_id: str) -> BaseUpstreamProvider | None:
"""Get UpstreamProvider for model ID from global cache."""
return _provider_map.get(model_id)
@@ -83,9 +83,7 @@ async def refresh_model_maps() -> None:
# Gather database overrides and disabled models
async with create_session() as session:
result = await session.exec(
select(ModelRow).where(col(ModelRow.enabled).is_(True))
)
result = await session.exec(select(ModelRow).where(ModelRow.enabled))
override_rows = result.all()
provider_result = await session.exec(select(UpstreamProviderRow))
@@ -103,7 +101,7 @@ async def refresh_model_maps() -> None:
}
disabled_result = await session.exec(
select(ModelRow.id).where(col(ModelRow.enabled).is_(False))
select(ModelRow.id).where(ModelRow.enabled == False) # noqa: E712
)
disabled_model_ids = {row for row in disabled_result.all()}
+34
View File
@@ -0,0 +1,34 @@
from .anthropic import AnthropicUpstreamProvider
from .azure import AzureUpstreamProvider
from .base import BaseUpstreamProvider
from .generic import GenericUpstreamProvider
from .helpers import (
_instantiate_provider,
_seed_providers_from_settings,
get_all_models_with_overrides,
init_upstreams,
refresh_upstreams_models_periodically,
resolve_model_alias,
)
from .ollama import OllamaUpstreamProvider
from .openai import OpenAIUpstreamProvider
from .openrouter import OpenRouterUpstreamProvider
__all__ = [
# upstreams
"AnthropicUpstreamProvider",
"AzureUpstreamProvider",
"BaseUpstreamProvider",
"GenericUpstreamProvider",
"OllamaUpstreamProvider",
"OpenAIUpstreamProvider",
"OpenRouterUpstreamProvider",
# helpers
"resolve_model_alias",
"get_all_models_with_overrides",
"get_model_with_override",
"refresh_upstreams_models_periodically",
"init_upstreams",
"_seed_providers_from_settings",
"_instantiate_provider",
]
+43
View File
@@ -0,0 +1,43 @@
from ..payment.models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
class AnthropicUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for Anthropic API."""
def __init__(self, api_key: str, provider_fee: float = 1.01):
self.upstream_name = "anthropic"
super().__init__(
base_url="https://api.anthropic.com/v1",
api_key=api_key,
provider_fee=provider_fee,
)
def transform_model_name(self, model_id: str) -> str:
"""Strip 'anthropic/' prefix for Anthropic API compatibility and transform model names."""
if model_id.startswith("anthropic/"):
model_id = model_id[len("anthropic/") :]
fixed_transforms = {
"claude-haiku-4.5": "claude-haiku-4-5-20251001",
"claude-sonnet-4.5": "claude-sonnet-4-5-20250929",
"claude-opus-4.1": "claude-opus-4-1-20250805",
"claude-opus-4": "claude-opus-4-20250514",
"claude-sonnet-4": "claude-sonnet-4-20250514",
"claude-3.5-haiku": "claude-3-5-haiku-20241022",
"claude-3-haiku": "claude-3-haiku-20240307",
"claude-haiku-4-5": "claude-haiku-4-5-20251001",
"claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
"claude-opus-4-1": "claude-opus-4-1-20250805",
"claude-3-5-haiku": "claude-3-5-haiku-20241022",
}
if model_id in fixed_transforms:
model_id = fixed_transforms[model_id]
return model_id
async def fetch_models(self) -> list[Model]:
"""Fetch Anthropic models from OpenRouter API filtered by anthropic source."""
models_data = await async_fetch_openrouter_models(source_filter="anthropic")
models = [Model(**model) for model in models_data] # type: ignore
for model in models:
model.alias_ids = [self.transform_model_name(model.id)]
return models
+46
View File
@@ -0,0 +1,46 @@
from typing import Mapping
from .base import BaseUpstreamProvider
class AzureUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for Azure OpenAI Service."""
def __init__(
self,
base_url: str,
api_key: str,
api_version: str,
provider_fee: float = 1.01,
):
"""Initialize Azure provider with API key and version.
Args:
base_url: Azure OpenAI endpoint base URL
api_key: Azure OpenAI API key for authentication
api_version: Azure OpenAI API version (e.g., "2024-02-15-preview")
provider_fee: Provider fee multiplier (default 1.01 for 1% fee)
"""
super().__init__(
base_url=base_url,
api_key=api_key,
provider_fee=provider_fee,
)
self.api_version = api_version
def prepare_params(
self, path: str, query_params: Mapping[str, str] | None
) -> Mapping[str, str]:
"""Prepare query parameters for Azure OpenAI, adding API version.
Args:
path: Request path
query_params: Original query parameters from the client
Returns:
Query parameters dict with Azure API version added for chat completions
"""
params = dict(query_params or {})
if path.endswith("chat/completions"):
params["api-version"] = self.api_version
return params
@@ -4,7 +4,7 @@ import json
import re
import traceback
from collections.abc import AsyncGenerator
from typing import Mapping
from typing import Mapping, Optional
import httpx
from fastapi import BackgroundTasks, HTTPException, Request
@@ -25,7 +25,6 @@ from ..payment.models import (
Pricing,
_calculate_usd_max_costs,
_update_model_sats_pricing,
async_fetch_openrouter_models,
)
from ..payment.price import sats_usd_price
from ..wallet import recieve_token, send_token
@@ -33,7 +32,7 @@ from ..wallet import recieve_token, send_token
logger = get_logger(__name__)
class UpstreamProvider:
class BaseUpstreamProvider:
"""Provider for forwarding requests to an upstream AI service API."""
base_url: str
@@ -876,13 +875,14 @@ class UpstreamProvider:
)
async def get_x_cashu_cost(
self, response_data: dict, max_cost_for_model: int
self, response_data: dict, max_cost_for_model: int, request_data: Optional[dict] = None
) -> MaxCostData | CostData | None:
"""Calculate cost for X-Cashu payment based on response data.
Args:
response_data: Response data containing model and usage information
max_cost_for_model: Maximum cost for the model
request_data: Original request data containing messages (for image token calculation)
Returns:
Cost data object (MaxCostData or CostData) or None if calculation fails
@@ -894,7 +894,7 @@ class UpstreamProvider:
)
async with create_session() as session:
match await calculate_cost(response_data, max_cost_for_model, session):
match await calculate_cost(response_data, max_cost_for_model, session, request_data):
case MaxCostData() as cost:
logger.debug(
"Using max cost pricing",
@@ -1018,6 +1018,7 @@ class UpstreamProvider:
unit: str,
max_cost_for_model: int,
mint: str | None = None,
request_data: Optional[dict] = None,
) -> StreamingResponse:
"""Handle streaming response for X-Cashu payment, calculating refund if needed.
@@ -1076,7 +1077,7 @@ class UpstreamProvider:
response_data = {"usage": usage_data, "model": model}
try:
cost_data = await self.get_x_cashu_cost(
response_data, max_cost_for_model
response_data, max_cost_for_model, request_data
)
if cost_data:
if unit == "msat":
@@ -1151,6 +1152,7 @@ class UpstreamProvider:
unit: str,
max_cost_for_model: int,
mint: str | None = None,
request_data: Optional[dict] = None,
) -> Response:
"""Handle non-streaming response for X-Cashu payment, calculating refund if needed.
@@ -1171,7 +1173,7 @@ class UpstreamProvider:
try:
response_json = json.loads(content_str)
cost_data = await self.get_x_cashu_cost(response_json, max_cost_for_model)
cost_data = await self.get_x_cashu_cost(response_json, max_cost_for_model, request_data)
if not cost_data:
logger.error(
@@ -1281,6 +1283,7 @@ class UpstreamProvider:
unit: str,
max_cost_for_model: int,
mint: str | None = None,
request_data: Optional[dict] = None,
) -> StreamingResponse | Response:
"""Handle chat completion response for X-Cashu payment, detecting streaming vs non-streaming.
@@ -1317,11 +1320,11 @@ class UpstreamProvider:
if is_streaming:
return await self.handle_x_cashu_streaming_response(
content_str, response, amount, unit, max_cost_for_model, mint
content_str, response, amount, unit, max_cost_for_model, mint, request_data
)
else:
return await self.handle_x_cashu_non_streaming_response(
content_str, response, amount, unit, max_cost_for_model, mint
content_str, response, amount, unit, max_cost_for_model, mint, request_data
)
except Exception as e:
@@ -1373,6 +1376,17 @@ class UpstreamProvider:
request_body = await request.body()
transformed_body = self.prepare_request_body(request_body, model_obj)
# Parse request data for image token calculation
request_data = None
try:
if request_body:
request_data = json.loads(request_body.decode('utf-8'))
except Exception as e:
logger.warning(
"Could not parse request body for image token calculation",
extra={"error": str(e)}
)
logger.debug(
"Forwarding request to upstream",
extra={
@@ -1458,7 +1472,7 @@ class UpstreamProvider:
)
result = await self.handle_x_cashu_chat_completion(
response, amount, unit, max_cost_for_model, mint
response, amount, unit, max_cost_for_model, mint, request_data
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
@@ -1626,6 +1640,7 @@ class UpstreamProvider:
enabled=model.enabled,
upstream_provider_id=model.upstream_provider_id,
canonical_slug=model.canonical_slug,
alias_ids=model.alias_ids,
)
(
@@ -1648,6 +1663,7 @@ class UpstreamProvider:
enabled=model.enabled,
upstream_provider_id=model.upstream_provider_id,
canonical_slug=model.canonical_slug,
alias_ids=model.alias_ids,
)
async def fetch_models(self) -> list[Model]:
@@ -1702,111 +1718,3 @@ class UpstreamProvider:
Model object or None if not found
"""
return self._models_by_id.get(model_id)
class OpenAIUpstreamProvider(UpstreamProvider):
"""Upstream provider specifically configured for OpenAI API."""
def __init__(self, api_key: str, provider_fee: float = 1.01):
self.upstream_name = "openai"
super().__init__(
base_url="https://api.openai.com/v1",
api_key=api_key,
provider_fee=provider_fee,
)
def transform_model_name(self, model_id: str) -> str:
"""Strip 'openai/' prefix for OpenAI API compatibility."""
return model_id.removeprefix("openai/")
async def fetch_models(self) -> list[Model]:
"""Fetch OpenAI models from OpenRouter API filtered by openai source."""
models_data = await async_fetch_openrouter_models(source_filter="openai")
return [Model(**model) for model in models_data] # type: ignore
class AnthropicUpstreamProvider(UpstreamProvider):
"""Upstream provider specifically configured for Anthropic API."""
def __init__(self, api_key: str, provider_fee: float = 1.01):
self.upstream_name = "anthropic"
super().__init__(
base_url="https://api.anthropic.com/v1",
api_key=api_key,
provider_fee=provider_fee,
)
def transform_model_name(self, model_id: str) -> str:
"""Strip 'anthropic/' prefix for Anthropic API compatibility."""
return model_id.removeprefix("anthropic/")
async def fetch_models(self) -> list[Model]:
"""Fetch Anthropic models from OpenRouter API filtered by anthropic source."""
models_data = await async_fetch_openrouter_models(source_filter="anthropic")
return [Model(**model) for model in models_data] # type: ignore
class AzureUpstreamProvider(UpstreamProvider):
"""Upstream provider specifically configured for Azure OpenAI Service."""
def __init__(
self,
base_url: str,
api_key: str,
api_version: str,
provider_fee: float = 1.01,
):
"""Initialize Azure provider with API key and version.
Args:
base_url: Azure OpenAI endpoint base URL
api_key: Azure OpenAI API key for authentication
api_version: Azure OpenAI API version (e.g., "2024-02-15-preview")
provider_fee: Provider fee multiplier (default 1.01 for 1% fee)
"""
super().__init__(
base_url=base_url,
api_key=api_key,
provider_fee=provider_fee,
)
self.api_version = api_version
def prepare_params(
self, path: str, query_params: Mapping[str, str] | None
) -> Mapping[str, str]:
"""Prepare query parameters for Azure OpenAI, adding API version.
Args:
path: Request path
query_params: Original query parameters from the client
Returns:
Query parameters dict with Azure API version added for chat completions
"""
params = dict(query_params or {})
if path.endswith("chat/completions"):
params["api-version"] = self.api_version
return params
class OpenRouterUpstreamProvider(UpstreamProvider):
"""Upstream provider specifically configured for OpenRouter API."""
def __init__(self, api_key: str, provider_fee: float = 1.06):
"""Initialize OpenRouter provider with API key.
Args:
api_key: OpenRouter API key for authentication
provider_fee: Provider fee multiplier (default 1.06 for 6% fee)
"""
self.upstream_name = "openrouter"
super().__init__(
base_url="https://openrouter.ai/api/v1",
api_key=api_key,
provider_fee=provider_fee,
)
async def fetch_models(self) -> list[Model]:
"""Fetch all OpenRouter models."""
models_data = await async_fetch_openrouter_models()
return [Model(**model) for model in models_data] # type: ignore
+24
View File
@@ -0,0 +1,24 @@
from ..payment.models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
class FireworksUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for Fireworks.ai API."""
upstream_name = "fireworks"
base_url = "https://api.fireworks.ai/inference/v1"
platform_url = "https://app.fireworks.ai/settings/users/api-keys"
def __init__(self, api_key: str, provider_fee: float = 1.01):
super().__init__(
base_url=self.base_url, api_key=api_key, provider_fee=provider_fee
)
def transform_model_name(self, model_id: str) -> str:
"""Strip 'fireworks/' prefix for Fireworks API compatibility."""
return model_id.removeprefix("fireworks/")
async def fetch_models(self) -> list[Model]:
"""Fetch Fireworks models from OpenRouter API filtered by fireworks source."""
models_data = await async_fetch_openrouter_models(source_filter="fireworks")
return [Model(**model) for model in models_data] # type: ignore
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
import httpx
from .upstream import UpstreamProvider
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..payment.models import Model
@@ -14,7 +14,7 @@ from ..core.logging import get_logger
logger = get_logger(__name__)
class GenericUpstreamProvider(UpstreamProvider):
class GenericUpstreamProvider(BaseUpstreamProvider):
"""Generic upstream provider that can fetch models from any OpenAI-compatible API."""
def __init__(
+24
View File
@@ -0,0 +1,24 @@
from ..payment.models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
class GroqUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for Groq API."""
upstream_name = "groq"
base_url = "https://api.groq.com/openai/v1"
platform_url = "https://console.groq.com/keys"
def __init__(self, api_key: str, provider_fee: float = 1.01):
super().__init__(
base_url=self.base_url, api_key=api_key, provider_fee=provider_fee
)
def transform_model_name(self, model_id: str) -> str:
"""Strip 'groq/' prefix for Groq API compatibility."""
return model_id.removeprefix("groq/")
async def fetch_models(self) -> list[Model]:
"""Fetch Groq models from OpenRouter API filtered by groq source."""
models_data = await async_fetch_openrouter_models(source_filter="groq")
return [Model(**model) for model in models_data] # type: ignore
@@ -5,26 +5,26 @@ import re
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .core.settings import Settings
from ..core.settings import Settings
from .core import get_logger
from .core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
from .payment.models import Model
from .upstreams import (
AnthropicUpstreamProvider,
AzureUpstreamProvider,
OllamaUpstreamProvider,
OpenAIUpstreamProvider,
OpenRouterUpstreamProvider,
UpstreamProvider,
)
from .upstreams.generic import GenericUpstreamProvider
from ..core import get_logger
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
from ..payment.models import Model
from .anthropic import AnthropicUpstreamProvider
from .azure import AzureUpstreamProvider
from .base import BaseUpstreamProvider
from .generic import GenericUpstreamProvider
from .ollama import OllamaUpstreamProvider
from .openai import OpenAIUpstreamProvider
from .openrouter import OpenRouterUpstreamProvider
logger = get_logger(__name__)
def resolve_model_alias(model_id: str, canonical_slug: str | None = None) -> list[str]:
def resolve_model_alias(
model_id: str, canonical_slug: str | None = None, alias_ids: list[str] | None = None
) -> list[str]:
"""Resolve model ID to all possible aliases.
Returns list of aliases including canonical slug and variations without provider prefix.
@@ -66,11 +66,14 @@ def resolve_model_alias(model_id: str, canonical_slug: str | None = None) -> lis
if canonical_base not in aliases:
aliases.append(canonical_base)
if alias_ids:
aliases.extend(alias_ids)
return aliases
async def get_all_models_with_overrides(
upstreams: list[UpstreamProvider],
upstreams: list[BaseUpstreamProvider],
) -> list[Model]:
"""Get all models from all providers with database overrides applied.
@@ -85,7 +88,7 @@ async def get_all_models_with_overrides(
"""
from sqlmodel import select
from .payment.models import _row_to_model
from ..payment.models import _row_to_model
async with create_session() as session:
result = await session.exec(select(ModelRow).where(ModelRow.enabled))
@@ -120,57 +123,8 @@ async def get_all_models_with_overrides(
return list(all_models.values())
async def get_model_with_override(
model_id: str,
upstreams: list[UpstreamProvider],
session: AsyncSession,
) -> Model | None:
"""Get a specific model from providers with database override applied.
Resolves model aliases automatically (e.g., both "gpt-5-mini" and "openai/gpt-5-mini").
Args:
model_id: Model identifier (with or without provider prefix)
upstreams: List of upstream provider instances
Returns:
Model object or None if not found
"""
from sqlmodel import select
from .payment.models import _row_to_model
aliases = resolve_model_alias(model_id)
for alias in aliases:
result = await session.exec(
select(ModelRow).where(
ModelRow.id == alias,
ModelRow.upstream_provider_id.isnot(None), # type: ignore
ModelRow.enabled,
)
)
override_row = result.first()
if override_row:
provider = await session.get(
UpstreamProviderRow, override_row.upstream_provider_id
)
provider_fee = provider.provider_fee if provider else 1.01
return _row_to_model(
override_row, apply_provider_fee=True, provider_fee=provider_fee
)
for alias in aliases:
for upstream in upstreams:
model = upstream.get_cached_model_by_id(alias)
if model and model.enabled:
return model
return None
async def refresh_upstreams_models_periodically(
upstreams: list[UpstreamProvider],
upstreams: list[BaseUpstreamProvider],
) -> None:
"""Background task to periodically refresh models cache for all providers.
@@ -180,7 +134,7 @@ async def refresh_upstreams_models_periodically(
import asyncio
import random
from .core.settings import settings
from ..core.settings import settings
interval = getattr(settings, "models_refresh_interval_seconds", 0)
if not interval or interval <= 0:
@@ -212,7 +166,7 @@ async def refresh_upstreams_models_periodically(
break
async def init_upstreams() -> list[UpstreamProvider]:
async def init_upstreams() -> list[BaseUpstreamProvider]:
"""Initialize upstream providers from database.
Seeds database with providers from settings if empty, then loads and instantiates
@@ -220,7 +174,7 @@ async def init_upstreams() -> list[UpstreamProvider]:
"""
from sqlmodel import select
from .core.settings import settings
from ..core.settings import settings
async with create_session() as session:
result = await session.exec(select(UpstreamProviderRow))
@@ -235,7 +189,7 @@ async def init_upstreams() -> list[UpstreamProvider]:
result = await session.exec(select(UpstreamProviderRow))
existing_providers = result.all()
upstreams: list[UpstreamProvider] = []
upstreams: list[BaseUpstreamProvider] = []
for provider_row in existing_providers:
if not provider_row.enabled:
logger.debug(f"Skipping disabled provider: {provider_row.base_url}")
@@ -266,7 +220,7 @@ async def _seed_providers_from_settings(
"""
from sqlmodel import select
from .core.settings import settings
from ..core.settings import settings
providers_to_add: list[UpstreamProviderRow] = []
seeded_base_urls: set[str] = set()
@@ -415,7 +369,9 @@ async def _seed_providers_from_settings(
)
def _instantiate_provider(provider_row: UpstreamProviderRow) -> UpstreamProvider | None:
def _instantiate_provider(
provider_row: UpstreamProviderRow,
) -> BaseUpstreamProvider | None:
"""Instantiate an UpstreamProvider from a database row.
Args:
@@ -462,7 +418,7 @@ def _instantiate_provider(provider_row: UpstreamProviderRow) -> UpstreamProvider
provider_row.provider_type,
)
elif provider_row.provider_type == "custom":
return UpstreamProvider(
return BaseUpstreamProvider(
provider_row.base_url, provider_row.api_key, provider_row.provider_fee
)
else:
@@ -6,7 +6,7 @@ import httpx
from fastapi import Request
from fastapi.responses import Response, StreamingResponse
from .upstream import UpstreamProvider
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..core.db import ApiKey, AsyncSession
@@ -17,7 +17,7 @@ from ..core.logging import get_logger
logger = get_logger(__name__)
class OllamaUpstreamProvider(UpstreamProvider):
class OllamaUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for Ollama API."""
def __init__(
@@ -121,7 +121,7 @@ class OllamaUpstreamProvider(UpstreamProvider):
models_list.append(
Model(
id=model_name,
name=model_name,
name=model_name.replace(":", " "),
created=0,
description=description,
context_length=context_length,
+23
View File
@@ -0,0 +1,23 @@
from ..payment.models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
class OpenAIUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for OpenAI API."""
def __init__(self, api_key: str, provider_fee: float = 1.01):
self.upstream_name = "openai"
super().__init__(
base_url="https://api.openai.com/v1",
api_key=api_key,
provider_fee=provider_fee,
)
def transform_model_name(self, model_id: str) -> str:
"""Strip 'openai/' prefix for OpenAI API compatibility."""
return model_id.removeprefix("openai/")
async def fetch_models(self) -> list[Model]:
"""Fetch OpenAI models from OpenRouter API filtered by openai source."""
models_data = await async_fetch_openrouter_models(source_filter="openai")
return [Model(**model) for model in models_data] # type: ignore
+25
View File
@@ -0,0 +1,25 @@
from ..payment.models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
class OpenRouterUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for OpenRouter API."""
def __init__(self, api_key: str, provider_fee: float = 1.06):
"""Initialize OpenRouter provider with API key.
Args:
api_key: OpenRouter API key for authentication
provider_fee: Provider fee multiplier (default 1.06 for 6% fee)
"""
self.upstream_name = "openrouter"
super().__init__(
base_url="https://openrouter.ai/api/v1",
api_key=api_key,
provider_fee=provider_fee,
)
async def fetch_models(self) -> list[Model]:
"""Fetch all OpenRouter models."""
models_data = await async_fetch_openrouter_models()
return [Model(**model) for model in models_data] # type: ignore
+26
View File
@@ -0,0 +1,26 @@
from ..payment.models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
class PerplexityUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for OpenAI API."""
upstream_name = "perplexity"
base_url = "https://api.perplexity.ai/" # without v1
platform_url = "https://www.perplexity.ai/account/api/keys"
def __init__(self, api_key: str, provider_fee: float = 1.01):
super().__init__(
base_url=self.base_url,
api_key=api_key,
provider_fee=provider_fee,
)
def transform_model_name(self, model_id: str) -> str:
"""Strip 'perplexity/' prefix for Perplexity API compatibility."""
return model_id.removeprefix("perplexity/")
async def fetch_models(self) -> list[Model]:
"""Fetch Perplexity models from OpenRouter API filtered by perplexity source."""
models_data = await async_fetch_openrouter_models(source_filter="perplexity")
return [Model(**model) for model in models_data] # type: ignore
+24
View File
@@ -0,0 +1,24 @@
from ..payment.models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
class XAIUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for XAI API."""
upstream_name = "xai"
base_url = "https://api.x.ai/v1"
platform_url = "https://accounts.x.ai/sign-up"
def __init__(self, api_key: str, provider_fee: float = 1.01):
super().__init__(
base_url=self.base_url, api_key=api_key, provider_fee=provider_fee
)
def transform_model_name(self, model_id: str) -> str:
"""Strip 'xai/' prefix for XAI API compatibility."""
return model_id.removeprefix("xai/")
async def fetch_models(self) -> list[Model]:
"""Fetch XAI models from OpenRouter API filtered by xai source."""
models_data = await async_fetch_openrouter_models(source_filter="xai")
return [Model(**model) for model in models_data] # type: ignore
-17
View File
@@ -1,17 +0,0 @@
from .ollama import OllamaUpstreamProvider
from .upstream import (
AnthropicUpstreamProvider,
AzureUpstreamProvider,
OpenAIUpstreamProvider,
OpenRouterUpstreamProvider,
UpstreamProvider,
)
__all__ = [
"OllamaUpstreamProvider",
"UpstreamProvider",
"AnthropicUpstreamProvider",
"AzureUpstreamProvider",
"OpenAIUpstreamProvider",
"OpenRouterUpstreamProvider",
]
+1
View File
@@ -49,6 +49,7 @@ else
npm run build
fi
rm -rf ../ui_out
mkdir -p ../ui_out
mv out/* ../ui_out
+1 -1
View File
@@ -34,7 +34,7 @@ export default function RootLayout({
return (
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased font-sans`}
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
>
<SuppressHydrationWarning>
<Providers>{children}</Providers>
+11 -12
View File
@@ -16,6 +16,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { useMemo, useState } from 'react';
import type { Model } from '@/lib/api/schemas/models';
import { groupAndSortModelsByProvider } from '@/lib/utils/modelSort';
export default function ModelsPage() {
const [filteredModels, setFilteredModels] = useState<Model[]>([]);
@@ -34,15 +35,7 @@ export default function ModelsPage() {
const groupedModels = useMemo(() => {
if (!models) return {};
return models.reduce<Record<string, typeof models>>((acc, model) => {
const provider = model.provider;
if (!acc[provider]) {
acc[provider] = [];
}
acc[provider].push(model);
return acc;
}, {});
return groupAndSortModelsByProvider(models);
}, [models]);
const groupDataMap = useMemo(() => {
@@ -52,7 +45,9 @@ export default function ModelsPage() {
const providerInfo = useMemo(() => {
return Object.entries(groupedModels).map(([provider, providerModels]) => {
const groupData = groupDataMap.get(provider);
const activeModels = providerModels.filter((m) => !m.soft_deleted).length;
const activeModels = providerModels.filter(
(m) => m.isEnabled && !m.soft_deleted
).length;
const totalModels = providerModels.length;
return {
@@ -156,7 +151,10 @@ export default function ModelsPage() {
models={models}
onFilteredModelsChange={setFilteredModels}
/>
<ModelSelector filteredModels={filteredModels} />
<ModelSelector
filteredModels={filteredModels}
showDeleteAllButton={true}
/>
</div>
</TabsContent>
@@ -183,7 +181,7 @@ export default function ModelsPage() {
(m) => m.soft_deleted
).length
}{' '}
soft deleted
disabled
</span>
)}
{groupData?.group_url && (
@@ -199,6 +197,7 @@ export default function ModelsPage() {
filterProvider={provider}
groupData={groupData}
showProviderActions={true}
showDeleteAllButton={false}
/>
</div>
</TabsContent>
+58 -38
View File
@@ -277,7 +277,11 @@ export default function ProvidersPage() {
}
placeholder='https://api.example.com/v1'
disabled={hasFixedBaseUrl(formData.provider_type)}
className={hasFixedBaseUrl(formData.provider_type) ? 'cursor-not-allowed opacity-60' : ''}
className={
hasFixedBaseUrl(formData.provider_type)
? 'cursor-not-allowed opacity-60'
: ''
}
/>
</div>
<div className='grid gap-2'>
@@ -453,7 +457,8 @@ export default function ProvidersPage() {
<div className='space-y-2'>
{providerModels.db_models.length === 0 ? (
<div className='text-muted-foreground py-4 text-center text-sm'>
No models configured. Add custom models to use this provider.
No models configured. Add custom models to
use this provider.
</div>
) : (
<div className='space-y-2'>
@@ -495,7 +500,10 @@ export default function ProvidersPage() {
</div>
) : (
// Has provided models - show tabs
<Tabs defaultValue='provided' className='w-full'>
<Tabs
defaultValue='provided'
className='w-full'
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger
value='provided'
@@ -504,7 +512,9 @@ export default function ProvidersPage() {
<span className='hidden sm:inline'>
Provided Models
</span>
<span className='sm:hidden'>Provided</span>
<span className='sm:hidden'>
Provided
</span>
<Badge
variant='secondary'
className='ml-1 text-xs sm:ml-2'
@@ -534,7 +544,8 @@ export default function ProvidersPage() {
>
{providerModels.db_models.length > 0 && (
<div className='text-muted-foreground mb-3 text-sm'>
Custom models override or extend the provider&apos;s catalog.
Custom models override or extend the
provider&apos;s catalog.
</div>
)}
{providerModels.db_models.length === 0 ? (
@@ -543,39 +554,42 @@ export default function ProvidersPage() {
</div>
) : (
<div className='space-y-2'>
{providerModels.db_models.map((model) => (
<div
key={model.id}
className='hover:bg-accent flex flex-col gap-2 rounded-lg border p-3 transition-colors sm:flex-row sm:items-center sm:justify-between'
>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2'>
<span className='truncate font-mono text-sm font-medium'>
{model.id}
</span>
<Badge
variant={
model.enabled
? 'default'
: 'secondary'
}
className='w-fit text-xs'
>
{model.enabled
? 'Enabled'
: 'Disabled'}
</Badge>
{providerModels.db_models.map(
(model) => (
<div
key={model.id}
className='hover:bg-accent flex flex-col gap-2 rounded-lg border p-3 transition-colors sm:flex-row sm:items-center sm:justify-between'
>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2'>
<span className='truncate font-mono text-sm font-medium'>
{model.id}
</span>
<Badge
variant={
model.enabled
? 'default'
: 'secondary'
}
className='w-fit text-xs'
>
{model.enabled
? 'Enabled'
: 'Disabled'}
</Badge>
</div>
<div className='text-muted-foreground mt-1 text-xs break-words'>
{model.description ||
model.name}
</div>
</div>
<div className='text-muted-foreground mt-1 text-xs break-words'>
{model.description || model.name}
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
</div>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
</div>
))}
)
)}
</div>
)}
</TabsContent>
@@ -583,9 +597,11 @@ export default function ProvidersPage() {
value='provided'
className='mt-4 space-y-2'
>
{providerModels.remote_models.length > 0 && (
{providerModels.remote_models.length >
0 && (
<div className='text-muted-foreground mb-3 text-sm'>
Models automatically discovered from the provider&apos;s catalog.
Models automatically discovered from the
provider&apos;s catalog.
</div>
)}
<div className='space-y-2'>
@@ -670,7 +686,11 @@ export default function ProvidersPage() {
}
placeholder='https://api.example.com/v1'
disabled={hasFixedBaseUrl(formData.provider_type)}
className={hasFixedBaseUrl(formData.provider_type) ? 'cursor-not-allowed opacity-60' : ''}
className={
hasFixedBaseUrl(formData.provider_type)
? 'cursor-not-allowed opacity-60'
: ''
}
/>
</div>
<div className='grid gap-2'>
+25 -28
View File
@@ -59,18 +59,24 @@ import {
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import {
sortModels,
groupAndSortModelsByProvider,
} from '@/lib/utils/modelSort';
interface ModelSelectorProps {
filterProvider?: string;
groupData?: ModelGroup;
showProviderActions?: boolean;
filteredModels?: Model[];
showDeleteAllButton?: boolean;
}
export function ModelSelector({
filterProvider,
groupData,
filteredModels: propFilteredModels,
showDeleteAllButton = false,
}: ModelSelectorProps) {
const [selectedModelId, setSelectedModelId] = useState<string>('');
const [, setHoveredModelId] = useState<string | null>(null);
@@ -387,14 +393,10 @@ export function ModelSelector({
providerIdNum,
model.id
);
await AdminService.updateProviderModel(
providerIdNum,
model.full_name,
{
...existingModel,
enabled: true,
}
);
await AdminService.updateProviderModel(providerIdNum, model.id, {
...existingModel,
enabled: true,
});
totalEnabled++;
} catch (error) {
console.error(`Failed to enable model ${model.full_name}:`, error);
@@ -426,20 +428,13 @@ export function ModelSelector({
: providerFilteredModels;
if (filterProvider) {
// If filtering by provider, return single group
return { [filterProvider]: modelsToGroup };
const sortedModels = sortModels(modelsToGroup);
return { [filterProvider]: sortedModels };
}
if (!modelsToGroup) return {};
return modelsToGroup.reduce<Record<string, Model[]>>((acc, model) => {
const provider = model.provider;
if (!acc[provider]) {
acc[provider] = [];
}
acc[provider].push(model);
return acc;
}, {});
return groupAndSortModelsByProvider(modelsToGroup);
}, [
providerFilteredModels,
filteredModels,
@@ -828,13 +823,15 @@ export function ModelSelector({
<Square className='mr-2 h-4 w-4' />
Deselect All
</Button>
<Button
onClick={handleDeleteAll}
className='text-destructive focus:text-destructive'
>
<AlertTriangle className='mr-2 h-4 w-4' />
Delete All Models Permanently
</Button>
{showDeleteAllButton && (
<Button
onClick={handleDeleteAll}
className='text-destructive focus:text-destructive'
>
<AlertTriangle className='mr-2 h-4 w-4' />
Delete All Overrides Permanently
</Button>
)}
{/* Model Management Actions
<Button onClick={() => setIsAddFormOpen(true)} className='gap-2'>
<Plus className='h-4 w-4' />
@@ -1077,9 +1074,9 @@ export function ModelSelector({
variant='ghost'
size='sm'
onClick={(e) => e.stopPropagation()}
className='h-8 w-8 p-0'
className='hover:bg-muted/50 dark:hover:bg-muted/80 h-8 w-8 p-0'
>
<MoreVertical className='h-4 w-4' />
<MoreVertical className='text-muted-foreground hover:text-foreground h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
@@ -1181,7 +1178,7 @@ export function ModelSelector({
{model.soft_deleted && (
<span className='inline-flex items-center rounded-full border border-red-300 bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800'>
<Trash2 className='mr-1 h-3 w-3' />
Deleted
Disabled
</span>
)}
</div>
+5 -3
View File
@@ -203,7 +203,8 @@ export function DetailedWalletBalance({
<div
className={cn(
'text-right font-mono',
!detail.error && ownerMsat > 0 &&
!detail.error &&
ownerMsat > 0 &&
'font-semibold text-green-600'
)}
>
@@ -250,7 +251,8 @@ export function DetailedWalletBalance({
<div
className={cn(
'truncate font-mono text-sm',
!detail.error && ownerMsat > 0 &&
!detail.error &&
ownerMsat > 0 &&
'font-semibold text-green-600'
)}
>
@@ -283,4 +285,4 @@ export function DetailedWalletBalance({
/>
</>
);
}
}
-16
View File
@@ -288,22 +288,6 @@ export function AdminSettings() {
</CardContent>
</Card>
{/* Security Settings */}
<Card>
<CardHeader>
<CardTitle>Security Settings</CardTitle>
<CardDescription>
Configure authentication and API access
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{renderSecretField(
'upstream_api_key',
'Upstream API Key',
'Enter API key'
)}
</CardContent>
</Card>
{/* Cashu Mints */}
<Card>
<CardHeader>
+5 -8
View File
@@ -11,10 +11,7 @@ import {
DollarSign,
Activity,
} from 'lucide-react';
import {
AdminService,
TemporaryBalance,
} from '@/lib/api/services/admin';
import { AdminService, TemporaryBalance } from '@/lib/api/services/admin';
import {
Card,
CardContent,
@@ -243,16 +240,16 @@ export function TemporaryBalances({
<div className='text-muted-foreground text-xs font-medium'>
Balance
</div>
<div className='truncate font-mono text-sm'>
{formatBalance(balance.balance)}
<div className='truncate font-mono text-sm'>
{formatBalance(balance.balance)}
</div>
</div>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
Spent
</div>
<div className='truncate font-mono text-sm'>
{formatBalance(balance.total_spent)}
<div className='truncate font-mono text-sm'>
{formatBalance(balance.total_spent)}
</div>
</div>
</div>
-1
View File
@@ -43,4 +43,3 @@ export function formatFromMsat(
});
return formatter.format(usd);
}
-1
View File
@@ -51,4 +51,3 @@ export async function fetchBtcUsdPrice(): Promise<number | null> {
export function btcToSatsRate(btcUsdPrice: number): number {
return btcUsdPrice / 100_000_000;
}
-1
View File
@@ -14,4 +14,3 @@ export function getDisplayUnitLabel(unit: DisplayUnit): string {
return unit;
}
}
+36
View File
@@ -0,0 +1,36 @@
import { type Model } from '@/lib/api/schemas/models';
export function sortModelsByStatus(a: Model, b: Model): number {
if (a.isEnabled && !b.isEnabled) return -1;
if (!a.isEnabled && b.isEnabled) return 1;
if (a.isEnabled === b.isEnabled) {
if (!a.soft_deleted && b.soft_deleted) return -1;
if (a.soft_deleted && !b.soft_deleted) return 1;
}
return 0;
}
export function sortModels(models: Model[]): Model[] {
return [...models].sort(sortModelsByStatus);
}
export function groupAndSortModelsByProvider(
models: Model[]
): Record<string, Model[]> {
const grouped = models.reduce<Record<string, Model[]>>((acc, model) => {
const provider = model.provider;
if (!acc[provider]) {
acc[provider] = [];
}
acc[provider].push(model);
return acc;
}, {});
Object.keys(grouped).forEach((provider) => {
grouped[provider].sort(sortModelsByStatus);
});
return grouped;
}