mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-31 07:46:15 +00:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60416bb5f6 | ||
|
|
bd0764ee0d | ||
|
|
b717c9739a | ||
|
|
14ae4ecce3 | ||
|
|
a8b6d4866f | ||
|
|
924f93c18d | ||
|
|
7c2ac805c8 | ||
|
|
bb9b632ceb | ||
|
|
50c43e9b07 | ||
|
|
a4c092d8dc | ||
|
|
94e7b2b4d2 | ||
|
|
637f3459c5 | ||
|
|
9a52e30470 | ||
|
|
30d62bf65c | ||
|
|
29b088c035 | ||
|
|
d16b0d5190 | ||
|
|
9b2a4a8ff8 | ||
|
|
320cfe82fd | ||
|
|
8d4691e7f6 | ||
|
|
d7c5d7ce41 | ||
|
|
2da2f96118 | ||
|
|
8cb73f4528 | ||
|
|
e3b146b83f | ||
|
|
b7e4fbf739 | ||
|
|
2c8ba93312 | ||
|
|
cfe03d6dcb | ||
|
|
80b6acbf4b | ||
|
|
bd88a84cd4 | ||
|
|
45f5ba96a8 | ||
|
|
4b935a6f4d | ||
|
|
c9f458b8ba | ||
|
|
d9d2e17e5d | ||
|
|
3d6bd65a64 | ||
|
|
8c08be9e11 | ||
|
|
c49da9bf84 | ||
|
|
c2b97f8e3b | ||
|
|
74480df47d | ||
|
|
f5c9cde852 | ||
|
|
88fbefbd18 | ||
|
|
ded82cd729 | ||
|
|
1e90b223cf | ||
|
|
b8a1d69924 | ||
|
|
33b19ba98b |
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "routstr"
|
||||
version = "0.2.0"
|
||||
version = "0.2.0c"
|
||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -19,6 +19,7 @@ dependencies = [
|
||||
"websockets>=12.0",
|
||||
"nostr>=0.0.2",
|
||||
"mdurl==0.1.2",
|
||||
"pillow>=10",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
+17
-13
@@ -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
|
||||
@@ -193,15 +193,15 @@ def create_model_mappings(
|
||||
Tuple of (model_instances, provider_map, unique_models)
|
||||
"""
|
||||
from .payment.models import _row_to_model
|
||||
from .upstream import resolve_model_alias
|
||||
from .upstream.helpers 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:
|
||||
|
||||
+184
-40
@@ -2739,45 +2739,9 @@ async def delete_upstream_provider(provider_id: int) -> dict[str, object]:
|
||||
@admin_router.get("/api/provider-types", dependencies=[Depends(require_admin_api)])
|
||||
async def get_provider_types() -> list[dict[str, object]]:
|
||||
"""Get metadata about available provider types including default URLs and whether they're fixed."""
|
||||
provider_types = [
|
||||
{
|
||||
"id": "openrouter",
|
||||
"name": "OpenRouter",
|
||||
"default_base_url": "https://openrouter.ai/api/v1",
|
||||
"fixed_base_url": True,
|
||||
},
|
||||
{
|
||||
"id": "openai",
|
||||
"name": "OpenAI",
|
||||
"default_base_url": "https://api.openai.com/v1",
|
||||
"fixed_base_url": True,
|
||||
},
|
||||
{
|
||||
"id": "anthropic",
|
||||
"name": "Anthropic",
|
||||
"default_base_url": "https://api.anthropic.com/v1",
|
||||
"fixed_base_url": True,
|
||||
},
|
||||
{
|
||||
"id": "azure",
|
||||
"name": "Azure OpenAI",
|
||||
"default_base_url": "",
|
||||
"fixed_base_url": False,
|
||||
},
|
||||
{
|
||||
"id": "ollama",
|
||||
"name": "Ollama",
|
||||
"default_base_url": "http://localhost:11434",
|
||||
"fixed_base_url": False,
|
||||
},
|
||||
{
|
||||
"id": "generic",
|
||||
"name": "Generic",
|
||||
"default_base_url": "",
|
||||
"fixed_base_url": False,
|
||||
},
|
||||
]
|
||||
return provider_types
|
||||
from ..upstream import upstream_provider_classes
|
||||
|
||||
return [cls.get_provider_metadata() for cls in upstream_provider_classes]
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
@@ -2785,7 +2749,7 @@ async def get_provider_types() -> list[dict[str, object]]:
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def get_provider_models(provider_id: int) -> dict[str, object]:
|
||||
from ..upstream import _instantiate_provider
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
@@ -2826,6 +2790,186 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
class CreateAccountRequest(BaseModel):
|
||||
provider_type: str
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/create-account",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def create_provider_account_by_type(
|
||||
payload: CreateAccountRequest,
|
||||
) -> dict[str, object]:
|
||||
"""Create a new account with a provider by provider type (before provider exists in DB)."""
|
||||
from ..upstream import upstream_provider_classes
|
||||
|
||||
provider_class = next(
|
||||
(
|
||||
cls
|
||||
for cls in upstream_provider_classes
|
||||
if cls.provider_type == payload.provider_type
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not provider_class:
|
||||
raise HTTPException(status_code=404, detail="Provider type not found")
|
||||
|
||||
try:
|
||||
account_data = await provider_class.create_account_static()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"account_data": account_data,
|
||||
"message": "Account created successfully",
|
||||
}
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider does not support account creation: {str(e)}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to create account for provider type {payload.provider_type}: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
class TopupRequest(BaseModel):
|
||||
amount: int
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/{provider_id}/topup",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def initiate_provider_topup(
|
||||
provider_id: int, payload: TopupRequest
|
||||
) -> dict[str, object]:
|
||||
"""Initiate a Lightning Network top-up for the upstream provider account."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
|
||||
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")
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"Initiating top-up for provider {provider_id}",
|
||||
extra={"amount": payload.amount},
|
||||
)
|
||||
topup_data = await upstream_instance.initiate_topup(payload.amount)
|
||||
logger.info(
|
||||
"Top-up initiated successfully",
|
||||
extra={
|
||||
"provider_id": provider_id,
|
||||
"invoice_id": topup_data.invoice_id,
|
||||
"amount": topup_data.amount,
|
||||
},
|
||||
)
|
||||
|
||||
response_data = {
|
||||
"ok": True,
|
||||
"topup_data": {
|
||||
"invoice_id": topup_data.invoice_id,
|
||||
"payment_request": topup_data.payment_request,
|
||||
"amount": topup_data.amount,
|
||||
"currency": topup_data.currency,
|
||||
"expires_at": topup_data.expires_at,
|
||||
"checkout_url": topup_data.checkout_url,
|
||||
},
|
||||
"message": "Top-up initiated successfully",
|
||||
}
|
||||
logger.info("Returning response", extra={"response": response_data})
|
||||
return response_data
|
||||
except NotImplementedError as e:
|
||||
logger.error(f"Provider does not support top-up: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Provider does not support top-up: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to initiate top-up for provider {provider_id}: {e}",
|
||||
extra={"error_type": type(e).__name__, "error": str(e)},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/upstream-providers/{provider_id}/topup/{invoice_id}/status",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, object]:
|
||||
"""Check the status of a Lightning Network top-up invoice."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
from ..upstream.ppqai import PPQAIUpstreamProvider
|
||||
|
||||
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")
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
if not isinstance(upstream_instance, PPQAIUpstreamProvider):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Provider does not support top-up status checking",
|
||||
)
|
||||
|
||||
try:
|
||||
paid = await upstream_instance.check_topup_status(invoice_id)
|
||||
return {"ok": True, "paid": paid}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to check top-up status for provider {provider_id}: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/upstream-providers/{provider_id}/balance",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def get_provider_balance(provider_id: int) -> dict[str, object]:
|
||||
"""Get the current account balance for the upstream provider."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
|
||||
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")
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
try:
|
||||
balance_data = await upstream_instance.get_balance()
|
||||
return {"ok": True, "balance_data": balance_data}
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider does not support balance checking: {str(e)}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch balance for provider {provider_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/openrouter-presets",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
|
||||
@@ -34,9 +34,9 @@ setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
if os.getenv("VERSION_SUFFIX") is not None:
|
||||
__version__ = f"0.2.0-{os.getenv('VERSION_SUFFIX')}"
|
||||
__version__ = f"0.2.0c-{os.getenv('VERSION_SUFFIX')}"
|
||||
else:
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.2.0c"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -78,7 +78,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
from ..payment.price import _update_prices
|
||||
from ..proxy import get_upstreams
|
||||
from ..upstream import refresh_upstreams_models_periodically
|
||||
from ..upstream.helpers import refresh_upstreams_models_periodically
|
||||
|
||||
await _update_prices()
|
||||
await initialize_upstreams()
|
||||
|
||||
@@ -235,7 +235,7 @@ class SettingsService:
|
||||
|
||||
merged_dict: dict[str, Any] = dict(env_resolved.dict())
|
||||
merged_dict.update(
|
||||
{k: v for k, v in db_json.items() if v not in (None, "", []) and v}
|
||||
{k: v for k, v in db_json.items() if v not in (None, "", [], {})}
|
||||
)
|
||||
|
||||
# Ensure primary_mint is consistent with cashu_mints if not explicitly set
|
||||
|
||||
@@ -25,7 +25,7 @@ class CostDataError(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
async def calculate_cost(
|
||||
async def calculate_cost( # todo: can be sync
|
||||
response_data: dict, max_cost: int, session: AsyncSession
|
||||
) -> CostData | MaxCostData | CostDataError:
|
||||
"""
|
||||
@@ -78,13 +78,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(
|
||||
|
||||
+185
-11
@@ -1,9 +1,13 @@
|
||||
import base64
|
||||
import json
|
||||
import math
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, Response
|
||||
from fastapi.requests import Request
|
||||
from PIL import Image
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..core import get_logger
|
||||
@@ -104,11 +108,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
|
||||
@@ -174,13 +176,23 @@ async def calculate_discounted_max_cost(
|
||||
|
||||
if messages := body.get("messages"):
|
||||
prompt_tokens = estimate_tokens(messages)
|
||||
|
||||
image_tokens = await estimate_image_tokens_in_messages(messages)
|
||||
if image_tokens > 0:
|
||||
logger.debug(
|
||||
"Found images in request",
|
||||
extra={
|
||||
"model": model,
|
||||
"image_tokens": image_tokens,
|
||||
},
|
||||
)
|
||||
prompt_tokens += image_tokens
|
||||
|
||||
estimated_prompt_delta_sats = (
|
||||
max_prompt_allowed_sats - prompt_tokens * model_pricing.prompt
|
||||
)
|
||||
if estimated_prompt_delta_sats >= 0:
|
||||
if estimated_prompt_delta_sats > 0:
|
||||
adjusted = adjusted - math.floor(estimated_prompt_delta_sats * 1000)
|
||||
else:
|
||||
adjusted = adjusted + math.ceil(-estimated_prompt_delta_sats * 1000)
|
||||
|
||||
max_tokens_raw = body.get("max_tokens", None)
|
||||
if max_tokens_raw is not None:
|
||||
@@ -195,10 +207,8 @@ async def calculate_discounted_max_cost(
|
||||
estimated_completion_delta_sats = (
|
||||
max_completion_allowed_sats - max_tokens_int * model_pricing.completion
|
||||
)
|
||||
if estimated_completion_delta_sats >= 0:
|
||||
if estimated_completion_delta_sats > 0:
|
||||
adjusted = adjusted - math.floor(estimated_completion_delta_sats * 1000)
|
||||
else:
|
||||
adjusted = adjusted + math.ceil(-estimated_completion_delta_sats * 1000)
|
||||
|
||||
logger.debug(
|
||||
"Discounted max cost computed",
|
||||
@@ -214,7 +224,171 @@ async def calculate_discounted_max_cost(
|
||||
|
||||
|
||||
def estimate_tokens(messages: list) -> int:
|
||||
return len(str(messages)) // 3
|
||||
"""Estimate tokens for text content, excluding image_url fields."""
|
||||
total = 0
|
||||
for msg in messages:
|
||||
if isinstance(msg, dict):
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
total += len(content)
|
||||
elif isinstance(content, list):
|
||||
total += sum(
|
||||
len(item.get("text", ""))
|
||||
for item in content
|
||||
if isinstance(item, dict) and item.get("type") == "text"
|
||||
)
|
||||
return total // 3
|
||||
|
||||
|
||||
def _get_image_dimensions(image_data: bytes) -> tuple[int, int]:
|
||||
"""Extract image dimensions from image bytes."""
|
||||
try:
|
||||
img = Image.open(BytesIO(image_data))
|
||||
return img.size
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to get image dimensions, using default",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
return (512, 512)
|
||||
|
||||
|
||||
async def _fetch_image_from_url(url: str) -> bytes | None:
|
||||
"""Fetch image from URL."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to fetch image from URL",
|
||||
extra={"error": str(e), "url": url[:100]},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _calculate_image_tokens(width: int, height: int, detail: str = "auto") -> int:
|
||||
"""Calculate image tokens based on OpenAI's vision pricing.
|
||||
|
||||
For low detail: 85 tokens
|
||||
For high detail/auto: 85 base tokens + 170 tokens per 512px tile
|
||||
"""
|
||||
if detail == "low":
|
||||
return 85
|
||||
|
||||
if width > 2048 or height > 2048:
|
||||
aspect_ratio = width / height
|
||||
if width > height:
|
||||
width = 2048
|
||||
height = int(width / aspect_ratio)
|
||||
else:
|
||||
height = 2048
|
||||
width = int(height * aspect_ratio)
|
||||
|
||||
if width > 768 or height > 768:
|
||||
aspect_ratio = width / height
|
||||
if width > height:
|
||||
width = 768
|
||||
height = int(width / aspect_ratio)
|
||||
else:
|
||||
height = 768
|
||||
width = int(height * aspect_ratio)
|
||||
|
||||
tiles_width = (width + 511) // 512
|
||||
tiles_height = (height + 511) // 512
|
||||
num_tiles = tiles_width * tiles_height
|
||||
|
||||
return 85 + (170 * num_tiles)
|
||||
|
||||
|
||||
async def estimate_image_tokens_in_messages(messages: list) -> int:
|
||||
"""Estimate total tokens for all images in messages.
|
||||
|
||||
Supports both base64 encoded images and image URLs.
|
||||
"""
|
||||
total_image_tokens = 0
|
||||
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
|
||||
content = message.get("content")
|
||||
if not content:
|
||||
continue
|
||||
|
||||
if isinstance(content, str):
|
||||
continue
|
||||
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
|
||||
for content_item in content:
|
||||
if not isinstance(content_item, dict):
|
||||
continue
|
||||
|
||||
content_type = content_item.get("type")
|
||||
if content_type not in ("image_url", "input_image"):
|
||||
continue
|
||||
|
||||
image_url_data = content_item.get("image_url")
|
||||
if not image_url_data:
|
||||
continue
|
||||
|
||||
if isinstance(image_url_data, str):
|
||||
url = image_url_data
|
||||
detail = "auto"
|
||||
elif isinstance(image_url_data, dict):
|
||||
url = image_url_data.get("url", "")
|
||||
detail = image_url_data.get("detail", "auto")
|
||||
else:
|
||||
continue
|
||||
|
||||
if not url:
|
||||
continue
|
||||
|
||||
if url.startswith("data:image/"):
|
||||
try:
|
||||
header, base64_data = url.split(",", 1)
|
||||
image_bytes = base64.b64decode(base64_data)
|
||||
width, height = _get_image_dimensions(image_bytes)
|
||||
tokens = _calculate_image_tokens(width, height, detail)
|
||||
total_image_tokens += tokens
|
||||
logger.debug(
|
||||
"Calculated tokens for base64 image",
|
||||
extra={
|
||||
"width": width,
|
||||
"height": height,
|
||||
"detail": detail,
|
||||
"tokens": tokens,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to process base64 image",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
total_image_tokens += 85
|
||||
else:
|
||||
image_bytes_or_none = await _fetch_image_from_url(url)
|
||||
if image_bytes_or_none:
|
||||
width, height = _get_image_dimensions(image_bytes_or_none)
|
||||
tokens = _calculate_image_tokens(width, height, detail)
|
||||
total_image_tokens += tokens
|
||||
logger.debug(
|
||||
"Calculated tokens for URL image",
|
||||
extra={
|
||||
"url": url[:100],
|
||||
"width": width,
|
||||
"height": height,
|
||||
"detail": detail,
|
||||
"tokens": tokens,
|
||||
},
|
||||
)
|
||||
else:
|
||||
total_image_tokens += 85
|
||||
|
||||
return total_image_tokens
|
||||
|
||||
|
||||
def create_error_response(
|
||||
|
||||
+18
-12
@@ -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)
|
||||
@@ -339,28 +340,34 @@ def _calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
|
||||
if (cl := model.top_provider.context_length) and (
|
||||
mct := model.top_provider.max_completion_tokens
|
||||
):
|
||||
if cl <= mct:
|
||||
return (
|
||||
cl * prompt_price,
|
||||
cl * completion_price,
|
||||
cl * max(completion_price, prompt_price),
|
||||
)
|
||||
return (
|
||||
(cl - mct) * prompt_price,
|
||||
cl * prompt_price,
|
||||
mct * completion_price,
|
||||
(cl - mct) * prompt_price + mct * completion_price,
|
||||
)
|
||||
elif cl := model.top_provider.context_length:
|
||||
return (
|
||||
cl * 0.8 * prompt_price,
|
||||
cl * 0.2 * completion_price,
|
||||
cl * prompt_price,
|
||||
cl * completion_price,
|
||||
cl * max(completion_price, prompt_price),
|
||||
)
|
||||
elif mct := model.top_provider.max_completion_tokens:
|
||||
return (
|
||||
mct * 4 * prompt_price,
|
||||
mct * prompt_price,
|
||||
mct * completion_price,
|
||||
mct * completion_price,
|
||||
mct * 5 * prompt_price,
|
||||
)
|
||||
elif model.context_length:
|
||||
return (
|
||||
model.context_length * 0.8 * prompt_price,
|
||||
model.context_length * 0.2 * completion_price,
|
||||
model.context_length * prompt_price,
|
||||
model.context_length * completion_price,
|
||||
model.context_length * max(completion_price, prompt_price),
|
||||
)
|
||||
|
||||
p = prompt_price * 1_000_000
|
||||
@@ -409,6 +416,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 +585,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 +620,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 +633,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
|
||||
|
||||
+9
-10
@@ -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,15 @@ from .payment.helpers import (
|
||||
get_max_cost_for_model,
|
||||
)
|
||||
from .payment.models import Model
|
||||
from .upstream import UpstreamProvider, init_upstreams
|
||||
from .upstream import BaseUpstreamProvider
|
||||
from .upstream.helpers import 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 +54,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 +68,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 +84,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 +102,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()}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from .anthropic import AnthropicUpstreamProvider
|
||||
from .azure import AzureUpstreamProvider
|
||||
from .base import BaseUpstreamProvider
|
||||
from .fireworks import FireworksUpstreamProvider
|
||||
from .generic import GenericUpstreamProvider
|
||||
from .groq import GroqUpstreamProvider
|
||||
from .ollama import OllamaUpstreamProvider
|
||||
from .openai import OpenAIUpstreamProvider
|
||||
from .openrouter import OpenRouterUpstreamProvider
|
||||
from .perplexity import PerplexityUpstreamProvider
|
||||
from .ppqai import PPQAIUpstreamProvider
|
||||
from .xai import XAIUpstreamProvider
|
||||
|
||||
upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
AnthropicUpstreamProvider,
|
||||
AzureUpstreamProvider,
|
||||
FireworksUpstreamProvider,
|
||||
GenericUpstreamProvider,
|
||||
GroqUpstreamProvider,
|
||||
OllamaUpstreamProvider,
|
||||
OpenAIUpstreamProvider,
|
||||
OpenRouterUpstreamProvider,
|
||||
PerplexityUpstreamProvider,
|
||||
PPQAIUpstreamProvider,
|
||||
XAIUpstreamProvider,
|
||||
]
|
||||
"""List of all upstream classes"""
|
||||
|
||||
__all__ = [
|
||||
"BaseUpstreamProvider",
|
||||
*[cls.__name__ for cls in upstream_provider_classes],
|
||||
"upstream_provider_classes",
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class AnthropicUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for Anthropic API."""
|
||||
|
||||
provider_type = "anthropic"
|
||||
default_base_url = "https://api.anthropic.com/v1"
|
||||
platform_url = "https://console.anthropic.com/settings/keys"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url,
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "AnthropicUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Anthropic",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,76 @@
|
||||
from typing import TYPE_CHECKING, Mapping
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class AzureUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for Azure OpenAI Service."""
|
||||
|
||||
provider_type = "azure"
|
||||
default_base_url = None
|
||||
platform_url = "https://portal.azure.com/"
|
||||
|
||||
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
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "AzureUpstreamProvider | None":
|
||||
if not provider_row.api_version:
|
||||
return None
|
||||
return cls(
|
||||
base_url=provider_row.base_url,
|
||||
api_key=provider_row.api_key,
|
||||
api_version=provider_row.api_version,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Azure OpenAI",
|
||||
"default_base_url": "",
|
||||
"fixed_base_url": False,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1,18 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import traceback
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Mapping
|
||||
from typing import TYPE_CHECKING, Mapping
|
||||
|
||||
import httpx
|
||||
from fastapi import BackgroundTasks, HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..auth import adjust_payment_for_tokens
|
||||
from ..core import get_logger
|
||||
from ..core.db import ApiKey, AsyncSession, create_session
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
from ..payment.cost_caculation import (
|
||||
CostData,
|
||||
CostDataError,
|
||||
@@ -25,7 +31,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,12 +38,26 @@ from ..wallet import recieve_token, send_token
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class UpstreamProvider:
|
||||
class TopupData(BaseModel):
|
||||
"""Universal top-up data schema for Lightning Network invoices."""
|
||||
|
||||
invoice_id: str
|
||||
payment_request: str
|
||||
amount: int
|
||||
currency: str
|
||||
expires_at: int | None = None
|
||||
checkout_url: str | None = None
|
||||
|
||||
|
||||
class BaseUpstreamProvider:
|
||||
"""Provider for forwarding requests to an upstream AI service API."""
|
||||
|
||||
provider_type: str = "base"
|
||||
default_base_url: str | None = None
|
||||
platform_url: str | None = None
|
||||
|
||||
base_url: str
|
||||
api_key: str
|
||||
upstream_name: str | None = None
|
||||
provider_fee: float = 1.05
|
||||
_models_cache: list[Model] = []
|
||||
_models_by_id: dict[str, Model] = {}
|
||||
@@ -57,6 +76,42 @@ class UpstreamProvider:
|
||||
self._models_cache = []
|
||||
self._models_by_id = {}
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "BaseUpstreamProvider | None":
|
||||
"""Factory method to instantiate provider from database row.
|
||||
|
||||
Args:
|
||||
provider_row: Database row containing provider configuration
|
||||
|
||||
Returns:
|
||||
Instantiated provider or None if instantiation fails
|
||||
"""
|
||||
return cls(
|
||||
base_url=provider_row.base_url,
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
"""Get metadata about this provider type for API responses.
|
||||
|
||||
Returns:
|
||||
Dict with provider type metadata including id, name, default_base_url, fixed_base_url, platform_url, can_create_account, can_topup, can_show_balance
|
||||
"""
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": cls.provider_type.title(),
|
||||
"default_base_url": cls.default_base_url or "",
|
||||
"fixed_base_url": bool(cls.default_base_url),
|
||||
"platform_url": cls.platform_url,
|
||||
"can_create_account": False,
|
||||
"can_topup": False,
|
||||
"can_show_balance": False,
|
||||
}
|
||||
|
||||
def prepare_headers(self, request_headers: dict) -> dict:
|
||||
"""Prepare headers for upstream request by removing proxy-specific headers and adding authentication.
|
||||
|
||||
@@ -163,7 +218,7 @@ class UpstreamProvider:
|
||||
extra={
|
||||
"original": original_model,
|
||||
"transformed": transformed_model,
|
||||
"provider": self.upstream_name or self.base_url,
|
||||
"provider": self.provider_type or self.base_url,
|
||||
},
|
||||
)
|
||||
return json.dumps(data).encode()
|
||||
@@ -172,7 +227,7 @@ class UpstreamProvider:
|
||||
"Could not transform request body",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"provider": self.upstream_name or self.base_url,
|
||||
"provider": self.provider_type or self.base_url,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1626,6 +1681,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 +1704,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]:
|
||||
@@ -1656,8 +1713,96 @@ class UpstreamProvider:
|
||||
Returns:
|
||||
List of Model objects with pricing
|
||||
"""
|
||||
logger.debug(f"Fetching models for {self.upstream_name or self.base_url}")
|
||||
return []
|
||||
logger.debug(f"Fetching models for {self.provider_type or self.base_url}")
|
||||
|
||||
try:
|
||||
or_models, provider_models_response = await asyncio.gather(
|
||||
self._fetch_openrouter_models(),
|
||||
self._fetch_provider_models(),
|
||||
)
|
||||
|
||||
provider_model_ids = self._parse_model_ids(provider_models_response)
|
||||
|
||||
found_models = []
|
||||
not_found_models = []
|
||||
|
||||
for model_id in provider_model_ids:
|
||||
or_model = self._match_model(model_id, or_models)
|
||||
if or_model:
|
||||
try:
|
||||
model = Model(**or_model) # type: ignore
|
||||
found_models.append(model)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to parse model {model_id}",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
else:
|
||||
not_found_models.append(model_id)
|
||||
|
||||
logger.info(
|
||||
"Fetched models for provider",
|
||||
extra={
|
||||
"provider": self.provider_type or self.base_url,
|
||||
"found_count": len(found_models),
|
||||
"not_found_count": len(not_found_models),
|
||||
},
|
||||
)
|
||||
|
||||
if not_found_models:
|
||||
logger.debug(
|
||||
"Models not found in OpenRouter",
|
||||
extra={"not_found_models": not_found_models},
|
||||
)
|
||||
|
||||
return found_models
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error fetching models for {self.provider_type or self.base_url}",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
return []
|
||||
|
||||
async def _fetch_openrouter_models(self) -> list[dict]:
|
||||
"""Fetch models from OpenRouter API."""
|
||||
url = "https://openrouter.ai/api/v1/models"
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
models = response.json()
|
||||
return [
|
||||
model
|
||||
for model in models.get("data", [])
|
||||
if ":free" not in model.get("id", "").lower()
|
||||
]
|
||||
|
||||
async def _fetch_provider_models(self) -> dict:
|
||||
"""Fetch models from provider's API."""
|
||||
url = f"{self.base_url.rstrip('/')}/models"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else None
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def _parse_model_ids(self, response: dict) -> list[str]:
|
||||
"""Parse model IDs from provider response."""
|
||||
return [model.get("id") for model in response.get("data", []) if "id" in model]
|
||||
|
||||
def _match_model(self, model_id: str, or_models: list[dict]) -> dict | None:
|
||||
"""Match provider model ID with OpenRouter model."""
|
||||
return next(
|
||||
(
|
||||
model
|
||||
for model in or_models
|
||||
if (model.get("id") == model_id)
|
||||
or (model.get("id", "").split("/")[-1] == model_id)
|
||||
or (model.get("canonical_slug") == model_id)
|
||||
or (model.get("canonical_slug", "").split("/")[-1] == model_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
async def refresh_models_cache(self) -> None:
|
||||
"""Refresh the in-memory models cache from upstream API."""
|
||||
@@ -1675,12 +1820,12 @@ class UpstreamProvider:
|
||||
|
||||
self._models_by_id = {m.id: m for m in self._models_cache}
|
||||
logger.info(
|
||||
f"Refreshed models cache for {self.upstream_name or self.base_url}",
|
||||
f"Refreshed models cache for {self.provider_type or self.base_url}",
|
||||
extra={"model_count": len(models)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to refresh models cache for {self.upstream_name or self.base_url}",
|
||||
f"Failed to refresh models cache for {self.provider_type or self.base_url}",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
@@ -1703,110 +1848,58 @@ class UpstreamProvider:
|
||||
"""
|
||||
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
|
||||
@classmethod
|
||||
async def create_account_static(cls) -> dict[str, object]:
|
||||
"""Create a new account with the provider (class method, no instance needed).
|
||||
|
||||
Returns:
|
||||
Query parameters dict with Azure API version added for chat completions
|
||||
Dict with account creation details including api_key
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If provider does not support account creation
|
||||
"""
|
||||
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,
|
||||
raise NotImplementedError(
|
||||
f"Provider {cls.provider_type} does not support account creation"
|
||||
)
|
||||
|
||||
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
|
||||
async def create_account(self) -> dict[str, object]:
|
||||
"""Create a new account with the provider.
|
||||
|
||||
Returns:
|
||||
Dict with account creation details including api_key
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If provider does not support account creation
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"Provider {self.provider_type} does not support account creation"
|
||||
)
|
||||
|
||||
async def initiate_topup(self, amount: int) -> TopupData:
|
||||
"""Initiate a Lightning Network top-up for the provider account.
|
||||
|
||||
Args:
|
||||
amount: Amount in currency units to top up
|
||||
|
||||
Returns:
|
||||
TopupData with standardized invoice information
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If provider does not support top-up
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"Provider {self.provider_type} does not support top-up"
|
||||
)
|
||||
|
||||
async def get_balance(self) -> dict[str, object]:
|
||||
"""Get the current account balance from the provider.
|
||||
|
||||
Returns:
|
||||
Dict with balance information
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If provider does not support balance checking
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"Provider {self.provider_type} does not support balance checking"
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class FireworksUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for Fireworks.ai API."""
|
||||
|
||||
provider_type = "fireworks"
|
||||
default_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.default_base_url, api_key=api_key, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "FireworksUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Fireworks",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Strip 'fireworks/' prefix for Fireworks API compatibility."""
|
||||
return model_id.split("/")[-1]
|
||||
@@ -4,9 +4,10 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
from .upstream import UpstreamProvider
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
|
||||
from ..core.logging import get_logger
|
||||
@@ -14,9 +15,13 @@ 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."""
|
||||
|
||||
provider_type = "generic"
|
||||
default_base_url = "http://localhost:8888"
|
||||
platform_url = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
@@ -39,6 +44,26 @@ class GenericUpstreamProvider(UpstreamProvider):
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "GenericUpstreamProvider":
|
||||
return cls(
|
||||
base_url=provider_row.base_url,
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Generic",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": False,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from upstream API using /models endpoint."""
|
||||
from ..payment.models import Architecture, Model, Pricing, TopProvider
|
||||
@@ -0,0 +1,40 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class GroqUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for Groq API."""
|
||||
|
||||
provider_type = "groq"
|
||||
default_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.default_base_url, api_key=api_key, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "GroqUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Groq",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Strip 'groq/' prefix for Groq API compatibility."""
|
||||
return model_id.removeprefix("groq/")
|
||||
@@ -5,26 +5,20 @@ 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 .base import BaseUpstreamProvider
|
||||
|
||||
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 +60,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 +82,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 +117,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 +128,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:
|
||||
@@ -194,7 +142,7 @@ async def refresh_upstreams_models_periodically(
|
||||
await upstream.refresh_models_cache()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error refreshing models for {upstream.upstream_name or upstream.base_url}",
|
||||
f"Error refreshing models for {upstream.base_url}",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
@@ -212,7 +160,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 +168,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 +183,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,61 +214,47 @@ async def _seed_providers_from_settings(
|
||||
"""
|
||||
from sqlmodel import select
|
||||
|
||||
from .core.settings import settings
|
||||
from . import upstream_provider_classes
|
||||
|
||||
providers_to_add: list[UpstreamProviderRow] = []
|
||||
seeded_base_urls: set[str] = set()
|
||||
|
||||
openai_api_key = os.environ.get("OPENAI_API_KEY")
|
||||
if openai_api_key:
|
||||
base_url = "https://api.openai.com/v1"
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(UpstreamProviderRow.base_url == base_url)
|
||||
)
|
||||
if not result.first():
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="openai",
|
||||
base_url=base_url,
|
||||
api_key=openai_api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(base_url)
|
||||
provider_classes_by_type = {
|
||||
cls.provider_type: cls
|
||||
for cls in upstream_provider_classes # type: ignore[attr-defined]
|
||||
}
|
||||
|
||||
anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
if anthropic_api_key:
|
||||
base_url = "https://api.anthropic.com/v1"
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(UpstreamProviderRow.base_url == base_url)
|
||||
)
|
||||
if not result.first():
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="anthropic",
|
||||
base_url=base_url,
|
||||
api_key=anthropic_api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(base_url)
|
||||
env_mappings: list[tuple[str, str, str | None, str | None]] = [
|
||||
("OPENAI_API_KEY", "openai", None, None),
|
||||
("ANTHROPIC_API_KEY", "anthropic", None, None),
|
||||
("OPENROUTER_API_KEY", "openrouter", None, None),
|
||||
("GROQ_API_KEY", "groq", None, None),
|
||||
("PERPLEXITY_API_KEY", "perplexity", None, None),
|
||||
("FIREWORKS_API_KEY", "fireworks", None, None),
|
||||
("XAI_API_KEY", "xai", None, None),
|
||||
]
|
||||
|
||||
openrouter_api_key = os.environ.get("OPENROUTER_API_KEY")
|
||||
if openrouter_api_key:
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(UpstreamProviderRow.base_url == base_url)
|
||||
)
|
||||
if not result.first():
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="openrouter",
|
||||
base_url=base_url,
|
||||
api_key=openrouter_api_key,
|
||||
enabled=True,
|
||||
for env_key, provider_type, _, _ in env_mappings:
|
||||
api_key = os.environ.get(env_key)
|
||||
if api_key and provider_type in provider_classes_by_type:
|
||||
provider_class = provider_classes_by_type[provider_type]
|
||||
if provider_class.default_base_url: # type: ignore[attr-defined]
|
||||
base_url = provider_class.default_base_url # type: ignore[attr-defined]
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == base_url
|
||||
)
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(base_url)
|
||||
if not result.first():
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type=provider_type,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(base_url)
|
||||
|
||||
ollama_base_url = os.environ.get("OLLAMA_BASE_URL")
|
||||
if ollama_base_url:
|
||||
@@ -369,53 +303,27 @@ async def _seed_providers_from_settings(
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
if "api.openai.com" in base_url.lower():
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="openai",
|
||||
base_url=base_url,
|
||||
api_key=settings.upstream_api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
elif "api.anthropic.com" in base_url.lower():
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="anthropic",
|
||||
base_url=base_url,
|
||||
api_key=settings.upstream_api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
elif "openrouter.ai/api/v1" in base_url.lower():
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="openrouter",
|
||||
base_url=base_url,
|
||||
api_key=settings.upstream_api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="custom",
|
||||
base_url=base_url,
|
||||
api_key=settings.upstream_api_key,
|
||||
enabled=True,
|
||||
)
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="custom",
|
||||
base_url=base_url,
|
||||
api_key=settings.upstream_api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(base_url)
|
||||
|
||||
for provider in providers_to_add:
|
||||
session.add(provider)
|
||||
logger.info(
|
||||
f"Seeding {provider.provider_type} provider",
|
||||
f"Seeding {provider.provider_type} provider", # type: ignore[str-format]
|
||||
extra={"base_url": provider.base_url},
|
||||
)
|
||||
|
||||
|
||||
def _instantiate_provider(provider_row: UpstreamProviderRow) -> UpstreamProvider | None:
|
||||
def _instantiate_provider(
|
||||
provider_row: UpstreamProviderRow,
|
||||
) -> BaseUpstreamProvider | None:
|
||||
"""Instantiate an UpstreamProvider from a database row.
|
||||
|
||||
Args:
|
||||
@@ -424,53 +332,35 @@ def _instantiate_provider(provider_row: UpstreamProviderRow) -> UpstreamProvider
|
||||
Returns:
|
||||
Instantiated provider or None if provider type is unknown
|
||||
"""
|
||||
from . import upstream_provider_classes
|
||||
|
||||
try:
|
||||
if provider_row.provider_type == "openai":
|
||||
return OpenAIUpstreamProvider(
|
||||
provider_row.api_key, provider_row.provider_fee
|
||||
)
|
||||
elif provider_row.provider_type == "anthropic":
|
||||
return AnthropicUpstreamProvider(
|
||||
provider_row.api_key, provider_row.provider_fee
|
||||
)
|
||||
elif provider_row.provider_type == "azure":
|
||||
if not provider_row.api_version:
|
||||
provider_classes_by_type = {
|
||||
cls.provider_type: cls
|
||||
for cls in upstream_provider_classes # type: ignore[attr-defined]
|
||||
}
|
||||
|
||||
provider_class = provider_classes_by_type.get(provider_row.provider_type)
|
||||
|
||||
if provider_class:
|
||||
provider = provider_class.from_db_row(provider_row) # type: ignore[attr-defined]
|
||||
if provider is None:
|
||||
logger.error(
|
||||
"Azure provider missing api_version",
|
||||
f"Failed to instantiate {provider_row.provider_type} provider",
|
||||
extra={"base_url": provider_row.base_url},
|
||||
)
|
||||
return None
|
||||
return AzureUpstreamProvider(
|
||||
provider_row.base_url,
|
||||
provider_row.api_key,
|
||||
provider_row.api_version,
|
||||
provider_row.provider_fee,
|
||||
)
|
||||
elif provider_row.provider_type == "openrouter":
|
||||
return OpenRouterUpstreamProvider(
|
||||
provider_row.api_key, provider_row.provider_fee
|
||||
)
|
||||
elif provider_row.provider_type == "ollama":
|
||||
return OllamaUpstreamProvider(
|
||||
return provider
|
||||
|
||||
if provider_row.provider_type == "custom":
|
||||
return BaseUpstreamProvider(
|
||||
provider_row.base_url, provider_row.api_key, provider_row.provider_fee
|
||||
)
|
||||
elif provider_row.provider_type == "generic":
|
||||
return GenericUpstreamProvider(
|
||||
provider_row.base_url,
|
||||
provider_row.api_key,
|
||||
provider_row.provider_fee,
|
||||
provider_row.provider_type,
|
||||
)
|
||||
elif provider_row.provider_type == "custom":
|
||||
return UpstreamProvider(
|
||||
provider_row.base_url, provider_row.api_key, provider_row.provider_fee
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"Unknown provider type: {provider_row.provider_type}",
|
||||
extra={"base_url": provider_row.base_url},
|
||||
)
|
||||
return None
|
||||
|
||||
logger.error(
|
||||
f"Unknown provider type: {provider_row.provider_type}",
|
||||
extra={"base_url": provider_row.base_url},
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to instantiate provider: {e}",
|
||||
@@ -6,10 +6,10 @@ 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
|
||||
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
|
||||
from ..core.logging import get_logger
|
||||
@@ -17,9 +17,13 @@ from ..core.logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OllamaUpstreamProvider(UpstreamProvider):
|
||||
class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for Ollama API."""
|
||||
|
||||
provider_type = "ollama"
|
||||
default_base_url = "http://localhost:11434"
|
||||
platform_url = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "http://localhost:11434",
|
||||
@@ -33,13 +37,32 @@ class OllamaUpstreamProvider(UpstreamProvider):
|
||||
api_key: Optional API key (Ollama typically doesn't require one)
|
||||
provider_fee: Provider fee multiplier (default 1.01 for 1% fee)
|
||||
"""
|
||||
self.upstream_name = "ollama"
|
||||
super().__init__(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "OllamaUpstreamProvider":
|
||||
return cls(
|
||||
base_url=provider_row.base_url,
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Ollama",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": False,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Strip 'ollama/' prefix for Ollama API compatibility."""
|
||||
return model_id.removeprefix("ollama/")
|
||||
@@ -121,7 +144,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,
|
||||
@@ -192,12 +215,12 @@ class OllamaUpstreamProvider(UpstreamProvider):
|
||||
|
||||
self._models_by_id = {m.id: m for m in self._models_cache}
|
||||
logger.info(
|
||||
f"Refreshed models cache for {self.upstream_name or self.base_url}",
|
||||
f"Refreshed models cache for {self.base_url}",
|
||||
extra={"model_count": len(models)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to refresh models cache for {self.upstream_name or self.base_url}",
|
||||
f"Failed to refresh models cache for {self.base_url}",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class OpenAIUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for OpenAI API."""
|
||||
|
||||
provider_type = "openai"
|
||||
default_base_url = "https://api.openai.com/v1"
|
||||
platform_url = "https://platform.openai.com/api-keys"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "OpenAIUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "OpenAI",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,50 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class OpenRouterUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for OpenRouter API."""
|
||||
|
||||
provider_type = "openrouter"
|
||||
default_base_url = "https://openrouter.ai/api/v1"
|
||||
platform_url = "https://openrouter.ai/settings/keys"
|
||||
|
||||
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)
|
||||
"""
|
||||
super().__init__(
|
||||
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "OpenRouterUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "OpenRouter",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,50 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class PerplexityUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for Perplexity API."""
|
||||
|
||||
provider_type = "perplexity"
|
||||
default_base_url = "https://api.perplexity.ai/"
|
||||
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.default_base_url,
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "PerplexityUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Perplexity",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,409 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..core.logging import get_logger
|
||||
from ..payment.models import Architecture, Model, Pricing, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider, TopupData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PPQAIModelPricing(BaseModel):
|
||||
ui: dict[str, float]
|
||||
api: dict[str, float]
|
||||
|
||||
|
||||
class PPQAIModel(BaseModel):
|
||||
id: str
|
||||
provider: str
|
||||
name: str
|
||||
created_at: int
|
||||
context_length: int
|
||||
pricing: PPQAIModelPricing
|
||||
popular: bool
|
||||
|
||||
|
||||
class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider for PPQ.AI API with Lightning Network top-up support."""
|
||||
|
||||
provider_type = "ppqai"
|
||||
default_base_url = "https://api.ppq.ai"
|
||||
platform_url = "https://ppq.ai/api-docs"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.0):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "PPQAIUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "PPQ.AI",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
"can_create_account": True,
|
||||
"can_topup": True,
|
||||
"can_show_balance": True,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
return model_id
|
||||
|
||||
@classmethod
|
||||
async def create_account_static(cls) -> dict[str, object]:
|
||||
"""Create a new PPQ.AI account without requiring an instance.
|
||||
|
||||
Returns:
|
||||
Dict containing 'credit_id' and 'api_key' for the new account.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{cls.default_base_url}/accounts/create"
|
||||
|
||||
logger.info("Creating new PPQ.AI account", extra={"url": url})
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url)
|
||||
response.raise_for_status()
|
||||
account_data = response.json()
|
||||
|
||||
logger.info(
|
||||
"Successfully created PPQ.AI account",
|
||||
extra={
|
||||
"credit_id": account_data.get("credit_id"),
|
||||
"has_api_key": bool(account_data.get("api_key")),
|
||||
},
|
||||
)
|
||||
|
||||
return account_data
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from PPQ.AI API."""
|
||||
url = f"{self.base_url}/models"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
logger.debug(
|
||||
"Fetching models from PPQ.AI",
|
||||
extra={"url": url, "has_api_key": bool(self.api_key)},
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
models_data = data.get("data", [])
|
||||
logger.info(
|
||||
"Fetched models from PPQ.AI",
|
||||
extra={"model_count": len(models_data)},
|
||||
)
|
||||
|
||||
or_models = [
|
||||
Model(**model) # type: ignore
|
||||
for model in await async_fetch_openrouter_models()
|
||||
]
|
||||
|
||||
models = []
|
||||
for model_data in models_data:
|
||||
try:
|
||||
ppqai_model = PPQAIModel.parse_obj(model_data)
|
||||
or_model = next(
|
||||
(
|
||||
model
|
||||
for model in or_models
|
||||
if model.id == ppqai_model.id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if or_model:
|
||||
if input_price := ppqai_model.pricing.api.get(
|
||||
"input_per_1M"
|
||||
):
|
||||
or_model.pricing.prompt = input_price / 1_000_000
|
||||
if output_price := ppqai_model.pricing.api.get(
|
||||
"output_per_1M"
|
||||
):
|
||||
or_model.pricing.completion = output_price / 1_000_000
|
||||
if cl := ppqai_model.context_length:
|
||||
or_model.context_length = cl
|
||||
models.append(or_model)
|
||||
else:
|
||||
input_price = ppqai_model.pricing.api.get(
|
||||
"input_per_1M", 0.0
|
||||
)
|
||||
output_price = ppqai_model.pricing.api.get(
|
||||
"output_per_1M", 0.0
|
||||
)
|
||||
|
||||
models.append(
|
||||
Model(
|
||||
id=ppqai_model.id,
|
||||
name=ppqai_model.name,
|
||||
created=ppqai_model.created_at // 1000,
|
||||
description=f"{ppqai_model.provider} model",
|
||||
context_length=ppqai_model.context_length,
|
||||
architecture=Architecture(
|
||||
modality="text->text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="Unknown",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=Pricing(
|
||||
prompt=input_price / 1_000_000,
|
||||
completion=output_price / 1_000_000,
|
||||
request=0.0,
|
||||
image=0.0,
|
||||
web_search=0.0,
|
||||
internal_reasoning=0.0,
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to parse PPQ.AI model",
|
||||
extra={
|
||||
"model_id": model_data.get("id", "unknown"),
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
return models
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
"HTTP error fetching models from PPQ.AI",
|
||||
extra={
|
||||
"status_code": e.response.status_code,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error fetching models from PPQ.AI",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
return []
|
||||
|
||||
async def create_account(self) -> dict[str, object]:
|
||||
"""Create a new PPQ.AI account.
|
||||
|
||||
Returns:
|
||||
Dict containing 'credit_id' and 'api_key' for the new account.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/accounts/create"
|
||||
|
||||
logger.info("Creating new PPQ.AI account", extra={"url": url})
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url)
|
||||
response.raise_for_status()
|
||||
account_data = response.json()
|
||||
|
||||
logger.info(
|
||||
"Successfully created PPQ.AI account",
|
||||
extra={
|
||||
"credit_id": account_data.get("credit_id"),
|
||||
"has_api_key": bool(account_data.get("api_key")),
|
||||
},
|
||||
)
|
||||
|
||||
return account_data
|
||||
|
||||
async def create_lightning_topup(
|
||||
self, amount: int, currency: str
|
||||
) -> dict[str, object]:
|
||||
"""Create a Lightning Network top-up invoice for this account.
|
||||
|
||||
Args:
|
||||
amount: Amount to top up (in the specified currency)
|
||||
currency: Currency for the top-up (default: "USD")
|
||||
|
||||
Returns:
|
||||
Dict containing invoice details including 'invoice_id', 'payment_request', etc.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/topup/create/btc-lightning"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {"amount": amount, "currency": currency}
|
||||
|
||||
logger.info(
|
||||
"Creating Lightning top-up invoice",
|
||||
extra={"url": url, "amount": amount, "currency": currency},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
print(f"Payload: {payload}", "sending to", url)
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
invoice_data = response.json()
|
||||
|
||||
logger.info(
|
||||
"Successfully created Lightning top-up invoice",
|
||||
extra={
|
||||
"invoice_id": invoice_data.get("invoice_id"),
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
},
|
||||
)
|
||||
|
||||
return invoice_data
|
||||
|
||||
async def check_topup_status(self, invoice_id: str) -> bool:
|
||||
"""Check the status of a Lightning top-up invoice.
|
||||
|
||||
Args:
|
||||
invoice_id: The invoice ID to check
|
||||
|
||||
Returns:
|
||||
True if the invoice is paid (status == "Settled"), False otherwise
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/topup/status/{invoice_id}"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
logger.debug(
|
||||
"Checking Lightning top-up status",
|
||||
extra={"url": url, "invoice_id": invoice_id},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
status_data = response.json()
|
||||
|
||||
is_paid = status_data.get("status") == "Settled"
|
||||
|
||||
logger.debug(
|
||||
"Retrieved Lightning top-up status",
|
||||
extra={
|
||||
"invoice_id": invoice_id,
|
||||
"status": status_data.get("status"),
|
||||
"is_paid": is_paid,
|
||||
},
|
||||
)
|
||||
|
||||
return is_paid
|
||||
|
||||
async def initiate_topup(self, amount: int) -> TopupData:
|
||||
"""Initiate a Lightning Network top-up for the PPQ.AI account.
|
||||
|
||||
Args:
|
||||
amount: Amount in currency units to top up (will be sent to PPQ.AI API)
|
||||
|
||||
Returns:
|
||||
TopupData with standardized invoice information
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails
|
||||
"""
|
||||
ppq_response = await self.create_lightning_topup(amount, "USD")
|
||||
|
||||
logger.info(
|
||||
"PPQ.AI top-up response",
|
||||
extra={
|
||||
"ppq_response": ppq_response,
|
||||
"invoice_id": ppq_response.get("invoice_id"),
|
||||
"has_lightning_invoice": "lightning_invoice" in ppq_response,
|
||||
},
|
||||
)
|
||||
|
||||
expires_at_value = ppq_response.get("expires_at")
|
||||
checkout_url_value = ppq_response.get("checkout_url")
|
||||
|
||||
topup_data = TopupData(
|
||||
invoice_id=str(ppq_response["invoice_id"]),
|
||||
payment_request=str(ppq_response["lightning_invoice"]),
|
||||
amount=int(ppq_response["amount"])
|
||||
if isinstance(ppq_response["amount"], (int, float, str))
|
||||
else 0,
|
||||
currency=str(ppq_response["currency"]),
|
||||
expires_at=int(expires_at_value)
|
||||
if isinstance(expires_at_value, (int, float, str))
|
||||
and expires_at_value is not None
|
||||
else None,
|
||||
checkout_url=str(checkout_url_value)
|
||||
if checkout_url_value is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Created TopupData",
|
||||
extra={
|
||||
"invoice_id": topup_data.invoice_id,
|
||||
"payment_request_length": len(topup_data.payment_request),
|
||||
"amount": topup_data.amount,
|
||||
},
|
||||
)
|
||||
|
||||
return topup_data
|
||||
|
||||
async def get_balance(self) -> dict[str, object]:
|
||||
"""Get the current account balance from PPQ.AI.
|
||||
|
||||
Returns:
|
||||
Dict with balance information
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails
|
||||
"""
|
||||
return await self.check_balance()
|
||||
|
||||
async def check_balance(self) -> dict[str, object]:
|
||||
"""Check the account balance for this PPQ.AI account.
|
||||
|
||||
Returns:
|
||||
Dict containing balance information
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/credits/balance"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
logger.debug("Checking PPQ.AI account balance", extra={"url": url})
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, headers=headers, json={})
|
||||
response.raise_for_status()
|
||||
balance_data = response.json()
|
||||
|
||||
logger.debug(
|
||||
"Retrieved PPQ.AI account balance",
|
||||
extra={"balance": balance_data.get("balance")},
|
||||
)
|
||||
|
||||
return balance_data
|
||||
@@ -0,0 +1,46 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class XAIUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for XAI API."""
|
||||
|
||||
provider_type = "x-ai"
|
||||
default_base_url = "https://api.x.ai/v1"
|
||||
platform_url = "https://console.x.ai/"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "XAIUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "xAI",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Strip 'xai/' prefix for XAI API compatibility."""
|
||||
return model_id.removeprefix("x-ai/")
|
||||
|
||||
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="x-ai")
|
||||
return [Model(**model) for model in models_data] # type: ignore
|
||||
@@ -1,17 +0,0 @@
|
||||
from .ollama import OllamaUpstreamProvider
|
||||
from .upstream import (
|
||||
AnthropicUpstreamProvider,
|
||||
AzureUpstreamProvider,
|
||||
OpenAIUpstreamProvider,
|
||||
OpenRouterUpstreamProvider,
|
||||
UpstreamProvider,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"OllamaUpstreamProvider",
|
||||
"UpstreamProvider",
|
||||
"AnthropicUpstreamProvider",
|
||||
"AzureUpstreamProvider",
|
||||
"OpenAIUpstreamProvider",
|
||||
"OpenRouterUpstreamProvider",
|
||||
]
|
||||
@@ -49,6 +49,7 @@ else
|
||||
npm run build
|
||||
fi
|
||||
|
||||
rm -rf ../ui_out
|
||||
mkdir -p ../ui_out
|
||||
mv out/* ../ui_out
|
||||
|
||||
|
||||
@@ -512,7 +512,7 @@ async def integration_app(
|
||||
from routstr.core.settings import settings as _settings
|
||||
|
||||
# Passthrough discounted max cost to avoid dependence on MODELS in tests
|
||||
def _passthrough_discount(
|
||||
async def _passthrough_discount(
|
||||
max_cost_for_model: int,
|
||||
body: dict,
|
||||
model_obj: Any = None,
|
||||
|
||||
@@ -49,7 +49,7 @@ def create_test_model(
|
||||
def create_test_provider(name: str, base_url: str = "http://test.com") -> Mock:
|
||||
"""Helper to create a test provider mock."""
|
||||
provider = Mock()
|
||||
provider.upstream_name = name
|
||||
provider.provider_type = name
|
||||
provider.base_url = base_url
|
||||
return provider
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from routstr.payment.helpers import (
|
||||
_calculate_image_tokens,
|
||||
_get_image_dimensions,
|
||||
estimate_image_tokens_in_messages,
|
||||
)
|
||||
|
||||
|
||||
def create_test_image(width: int, height: int) -> bytes:
|
||||
"""Create a test image with specified dimensions."""
|
||||
img = Image.new("RGB", (width, height), color="red")
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="JPEG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def test_calculate_image_tokens_low_detail() -> None:
|
||||
"""Test that low detail images always return 85 tokens."""
|
||||
assert _calculate_image_tokens(100, 100, "low") == 85
|
||||
assert _calculate_image_tokens(1000, 1000, "low") == 85
|
||||
assert _calculate_image_tokens(2048, 2048, "low") == 85
|
||||
|
||||
|
||||
def test_calculate_image_tokens_high_detail_small() -> None:
|
||||
"""Test token calculation for small images."""
|
||||
tokens = _calculate_image_tokens(512, 512, "high")
|
||||
assert tokens == 85 + 170
|
||||
|
||||
|
||||
def test_calculate_image_tokens_high_detail_large() -> None:
|
||||
"""Test token calculation for large images that need tiling."""
|
||||
tokens = _calculate_image_tokens(768, 768, "high")
|
||||
assert tokens > 85
|
||||
|
||||
|
||||
def test_calculate_image_tokens_auto() -> None:
|
||||
"""Test that auto detail behaves like high detail."""
|
||||
width, height = 512, 512
|
||||
auto_tokens = _calculate_image_tokens(width, height, "auto")
|
||||
high_tokens = _calculate_image_tokens(width, height, "high")
|
||||
assert auto_tokens == high_tokens
|
||||
|
||||
|
||||
def test_get_image_dimensions() -> None:
|
||||
"""Test extracting dimensions from image bytes."""
|
||||
image_bytes = create_test_image(800, 600)
|
||||
width, height = _get_image_dimensions(image_bytes)
|
||||
assert width == 800
|
||||
assert height == 600
|
||||
|
||||
|
||||
def test_get_image_dimensions_invalid() -> None:
|
||||
"""Test that invalid image data returns default dimensions."""
|
||||
invalid_bytes = b"not an image"
|
||||
width, height = _get_image_dimensions(invalid_bytes)
|
||||
assert width == 512
|
||||
assert height == 512
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_estimate_image_tokens_base64() -> None:
|
||||
"""Test estimating tokens for base64 encoded images."""
|
||||
image_bytes = create_test_image(512, 512)
|
||||
base64_image = base64.b64encode(image_bytes).decode("utf-8")
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What's in this image?"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{base64_image}"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
tokens = await estimate_image_tokens_in_messages(messages)
|
||||
assert tokens > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_estimate_image_tokens_multiple_images() -> None:
|
||||
"""Test estimating tokens for multiple images."""
|
||||
image_bytes = create_test_image(512, 512)
|
||||
base64_image = base64.b64encode(image_bytes).decode("utf-8")
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Compare these images"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{base64_image}"},
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{base64_image}"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
tokens = await estimate_image_tokens_in_messages(messages)
|
||||
assert tokens > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_estimate_image_tokens_with_detail() -> None:
|
||||
"""Test that detail parameter affects token calculation."""
|
||||
image_bytes = create_test_image(512, 512)
|
||||
base64_image = base64.b64encode(image_bytes).decode("utf-8")
|
||||
|
||||
messages_low = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{base64_image}",
|
||||
"detail": "low",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
messages_high = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{base64_image}",
|
||||
"detail": "high",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
tokens_low = await estimate_image_tokens_in_messages(messages_low)
|
||||
tokens_high = await estimate_image_tokens_in_messages(messages_high)
|
||||
|
||||
assert tokens_low == 85
|
||||
assert tokens_high > tokens_low
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_estimate_image_tokens_no_images() -> None:
|
||||
"""Test that messages without images return 0 tokens."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello, how are you?"},
|
||||
{"role": "assistant", "content": "I'm doing well, thank you!"},
|
||||
]
|
||||
|
||||
tokens = await estimate_image_tokens_in_messages(messages)
|
||||
assert tokens == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_estimate_image_tokens_input_image_type() -> None:
|
||||
"""Test that input_image type is also supported."""
|
||||
image_bytes = create_test_image(512, 512)
|
||||
base64_image = base64.b64encode(image_bytes).decode("utf-8")
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": f"data:image/jpeg;base64,{base64_image}",
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
tokens = await estimate_image_tokens_in_messages(messages)
|
||||
assert tokens > 0
|
||||
+1
-1
@@ -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
@@ -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>
|
||||
|
||||
+2
-85
@@ -1,88 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { DetailedWalletBalance } from '@/components/detailed-wallet-balance';
|
||||
import { TemporaryBalances } from '@/components/temporary-balances';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import type { DisplayUnit } from '@/lib/types/units';
|
||||
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
|
||||
import { CheatSheet } from '@/components/landing/cheat-sheet';
|
||||
|
||||
export default function Page() {
|
||||
const [displayUnit, setDisplayUnit] = useState<DisplayUnit>('sat');
|
||||
|
||||
const { data: btcUsdPrice } = useQuery({
|
||||
queryKey: ['btc-usd-price'],
|
||||
queryFn: fetchBtcUsdPrice,
|
||||
refetchInterval: 120_000,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (displayUnit === 'usd' && usdPerSat === null) {
|
||||
setDisplayUnit('sat');
|
||||
}
|
||||
}, [displayUnit, usdPerSat]);
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset className='p-0'>
|
||||
<SiteHeader />
|
||||
<div className='container max-w-6xl px-4 py-8 md:px-6 lg:px-8'>
|
||||
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold tracking-tight'>
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Monitor and manage wallet balances
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
value={displayUnit}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setDisplayUnit(value as DisplayUnit);
|
||||
}
|
||||
}}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
>
|
||||
<ToggleGroupItem value='msat'>mSAT</ToggleGroupItem>
|
||||
<ToggleGroupItem value='sat'>sat</ToggleGroupItem>
|
||||
<ToggleGroupItem value='usd' disabled={!usdPerSat}>
|
||||
USD
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6'>
|
||||
<div className='col-span-full'>
|
||||
<DetailedWalletBalance
|
||||
refreshInterval={30000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-full'>
|
||||
<TemporaryBalances
|
||||
refreshInterval={60000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
return <CheatSheet />;
|
||||
}
|
||||
|
||||
+420
-44
@@ -51,9 +51,282 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
function ProviderBalance({ providerId }: { providerId: number }) {
|
||||
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
|
||||
const [topupAmount, setTopupAmount] = useState('');
|
||||
const [topupError, setTopupError] = useState('');
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [invoiceData, setInvoiceData] = useState<{
|
||||
payment_request: string;
|
||||
invoice_id: string;
|
||||
} | null>(null);
|
||||
const [paymentStatus, setPaymentStatus] = useState<'pending' | 'paid' | null>(
|
||||
null
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: balanceData, isLoading, error } = useQuery({
|
||||
queryKey: ['provider-balance', providerId],
|
||||
queryFn: () => AdminService.getProviderBalance(providerId),
|
||||
refetchInterval: 30000,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const { data: statusData } = useQuery({
|
||||
queryKey: ['topup-status', providerId, invoiceData?.invoice_id],
|
||||
queryFn: () =>
|
||||
AdminService.checkTopupStatus(providerId, invoiceData!.invoice_id),
|
||||
enabled: !!invoiceData && paymentStatus === 'pending',
|
||||
refetchInterval: 2000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (statusData?.paid === true) {
|
||||
setPaymentStatus('paid');
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-balance', providerId],
|
||||
});
|
||||
toast.success('Payment received!', {
|
||||
description: 'Your balance has been updated.',
|
||||
});
|
||||
}
|
||||
}, [statusData, queryClient, providerId]);
|
||||
|
||||
const topupMutation = useMutation({
|
||||
mutationFn: async (amount: number) => {
|
||||
console.log('Calling top-up API with:', { providerId, amount });
|
||||
try {
|
||||
const result = await AdminService.initiateProviderTopup(providerId, amount);
|
||||
console.log('API returned:', result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('API call failed:', err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
console.log('Top-up response:', data);
|
||||
console.log('Type of data:', typeof data);
|
||||
console.log('Keys in data:', Object.keys(data || {}));
|
||||
|
||||
if (
|
||||
data?.topup_data?.payment_request &&
|
||||
data?.topup_data?.invoice_id
|
||||
) {
|
||||
setInvoiceData({
|
||||
payment_request: data.topup_data.payment_request as string,
|
||||
invoice_id: data.topup_data.invoice_id as string,
|
||||
});
|
||||
setPaymentStatus('pending');
|
||||
} else {
|
||||
console.error('Missing invoice data:', data);
|
||||
console.error('topup_data:', data?.topup_data);
|
||||
toast.error('No invoice returned from provider');
|
||||
setIsTopupDialogOpen(false);
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('Top-up mutation error:', error);
|
||||
toast.error(`Failed to initiate top-up: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleTopup = () => {
|
||||
const amount = parseFloat(topupAmount);
|
||||
|
||||
if (isNaN(amount)) {
|
||||
setTopupError('Please enter a valid amount');
|
||||
return;
|
||||
}
|
||||
|
||||
if (amount < 1 || amount > 500) {
|
||||
setTopupError('Amount must be between $1 and $500');
|
||||
return;
|
||||
}
|
||||
|
||||
topupMutation.mutate(amount);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setIsTopupDialogOpen(false);
|
||||
setTopupAmount('');
|
||||
setTopupError('');
|
||||
setInvoiceData(null);
|
||||
setPaymentStatus(null);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className='h-9 w-24' />;
|
||||
}
|
||||
|
||||
if (error || !balanceData?.ok || !balanceData.balance_data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const balance = balanceData.balance_data;
|
||||
let displayValue = 'N/A';
|
||||
|
||||
if (typeof balance.balance === 'number') {
|
||||
displayValue = `$${balance.balance.toFixed(2)}`;
|
||||
} else if (typeof balance.balance === 'string') {
|
||||
displayValue = balance.balance;
|
||||
} else if (balance.amount !== undefined) {
|
||||
displayValue = `$${Number(balance.amount).toFixed(2)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setIsTopupDialogOpen(true)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className='w-full font-mono sm:w-auto'
|
||||
>
|
||||
{isHovered ? 'Top Up' : displayValue}
|
||||
</Button>
|
||||
|
||||
<Dialog open={isTopupDialogOpen} onOpenChange={handleCloseDialog}>
|
||||
<DialogContent className='sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{paymentStatus === 'paid'
|
||||
? 'Payment Confirmed!'
|
||||
: 'Top Up Balance'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{paymentStatus === 'paid'
|
||||
? 'Your account balance has been updated.'
|
||||
: invoiceData
|
||||
? 'Scan the QR code or copy the Lightning invoice to pay.'
|
||||
: 'Enter the amount you want to add to your account balance.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{paymentStatus === 'paid' ? (
|
||||
<div className='flex flex-col items-center gap-4 py-6'>
|
||||
<div className='rounded-full bg-green-100 p-3 dark:bg-green-900'>
|
||||
<svg
|
||||
className='h-12 w-12 text-green-600 dark:text-green-400'
|
||||
fill='none'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
>
|
||||
<path d='M5 13l4 4L19 7'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p className='text-center font-semibold'>
|
||||
Top-up successful!
|
||||
</p>
|
||||
</div>
|
||||
) : invoiceData ? (
|
||||
<div className='flex flex-col items-center gap-4 py-4'>
|
||||
<div className='rounded-lg border-2 border-gray-200 p-2 dark:border-gray-800'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodeURIComponent(invoiceData.payment_request)}`}
|
||||
alt='Lightning Invoice QR Code'
|
||||
className='h-64 w-64'
|
||||
/>
|
||||
</div>
|
||||
<div className='w-full space-y-2'>
|
||||
<Label htmlFor='invoice'>Lightning Invoice</Label>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='invoice'
|
||||
value={invoiceData.payment_request}
|
||||
readOnly
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
invoiceData.payment_request
|
||||
);
|
||||
toast.success('Invoice copied to clipboard!');
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{paymentStatus === 'pending' && (
|
||||
<p className='text-muted-foreground text-center text-sm'>
|
||||
Waiting for payment...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid gap-4 py-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='topup_amount'>Amount (USD)</Label>
|
||||
<Input
|
||||
id='topup_amount'
|
||||
type='number'
|
||||
placeholder='Enter amount (1-500)'
|
||||
value={topupAmount}
|
||||
onChange={(e) => {
|
||||
setTopupAmount(e.target.value);
|
||||
setTopupError('');
|
||||
}}
|
||||
min='1'
|
||||
max='500'
|
||||
step='0.01'
|
||||
/>
|
||||
{topupError && (
|
||||
<p className='text-sm text-red-600 dark:text-red-400'>
|
||||
{topupError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{paymentStatus === 'paid' ? (
|
||||
<Button onClick={handleCloseDialog} className='w-full'>
|
||||
Done
|
||||
</Button>
|
||||
) : invoiceData ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleCloseDialog}
|
||||
className='w-full'
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant='outline' onClick={handleCloseDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleTopup}
|
||||
disabled={topupMutation.isPending || !topupAmount}
|
||||
>
|
||||
{topupMutation.isPending
|
||||
? 'Processing...'
|
||||
: 'Generate Invoice'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProvidersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [editingProvider, setEditingProvider] =
|
||||
@@ -64,6 +337,7 @@ export default function ProvidersPage() {
|
||||
new Set()
|
||||
);
|
||||
const [viewingModels, setViewingModels] = useState<number | null>(null);
|
||||
const [isCreatingAccount, setIsCreatingAccount] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState<CreateUpstreamProvider>({
|
||||
provider_type: 'openrouter',
|
||||
@@ -138,6 +412,30 @@ export default function ProvidersPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateAccount = async () => {
|
||||
setIsCreatingAccount(true);
|
||||
try {
|
||||
const response = await AdminService.createProviderAccountByType(
|
||||
formData.provider_type
|
||||
);
|
||||
if (response.ok && response.account_data.api_key) {
|
||||
setFormData({
|
||||
...formData,
|
||||
api_key: String(response.account_data.api_key),
|
||||
});
|
||||
toast.success('Account created successfully! API key has been filled in.');
|
||||
} else {
|
||||
toast.success('Account created, but no API key returned.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
toast.error(`Failed to create account: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsCreatingAccount(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
provider_type: 'openrouter',
|
||||
@@ -194,6 +492,21 @@ export default function ProvidersPage() {
|
||||
return providerType?.fixed_base_url || false;
|
||||
};
|
||||
|
||||
const getPlatformUrl = (type: string) => {
|
||||
const providerType = providerTypes.find((pt) => pt.id === type);
|
||||
return providerType?.platform_url || null;
|
||||
};
|
||||
|
||||
const canCreateAccount = (type: string) => {
|
||||
const providerType = providerTypes.find((pt) => pt.id === type);
|
||||
return providerType?.can_create_account || false;
|
||||
};
|
||||
|
||||
const canShowBalance = (type: string) => {
|
||||
const providerType = providerTypes.find((pt) => pt.id === type);
|
||||
return providerType?.can_show_balance || false;
|
||||
};
|
||||
|
||||
const toggleProviderExpansion = (providerId: number) => {
|
||||
const newExpanded = new Set(expandedProviders);
|
||||
if (newExpanded.has(providerId)) {
|
||||
@@ -277,11 +590,42 @@ 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'>
|
||||
<Label htmlFor='api_key'>API Key</Label>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='api_key'>API Key</Label>
|
||||
{canCreateAccount(formData.provider_type) ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleCreateAccount}
|
||||
disabled={isCreatingAccount}
|
||||
className='h-6 text-xs'
|
||||
>
|
||||
{isCreatingAccount
|
||||
? 'Creating...'
|
||||
: 'Create Account'}
|
||||
</Button>
|
||||
) : (
|
||||
getPlatformUrl(formData.provider_type) && (
|
||||
<a
|
||||
href={getPlatformUrl(formData.provider_type)!}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300'
|
||||
>
|
||||
Get Your API Key Here →
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id='api_key'
|
||||
type='password'
|
||||
@@ -389,7 +733,11 @@ export default function ProvidersPage() {
|
||||
{provider.base_url}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className='grid grid-cols-3 gap-2 sm:flex sm:flex-nowrap'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{canShowBalance(provider.provider_type) &&
|
||||
provider.api_key && (
|
||||
<ProviderBalance providerId={provider.id} />
|
||||
)}
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
@@ -453,7 +801,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 +844,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 +856,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 +888,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's catalog.
|
||||
Custom models override or extend the
|
||||
provider's catalog.
|
||||
</div>
|
||||
)}
|
||||
{providerModels.db_models.length === 0 ? (
|
||||
@@ -543,39 +898,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 +941,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's catalog.
|
||||
Models automatically discovered from the
|
||||
provider's catalog.
|
||||
</div>
|
||||
)}
|
||||
<div className='space-y-2'>
|
||||
@@ -670,13 +1030,29 @@ 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'>
|
||||
<Label htmlFor='edit_api_key'>
|
||||
API Key (leave blank to keep current)
|
||||
</Label>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='edit_api_key'>
|
||||
API Key (leave blank to keep current)
|
||||
</Label>
|
||||
{getPlatformUrl(formData.provider_type) && (
|
||||
<a
|
||||
href={getPlatformUrl(formData.provider_type)!}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300'
|
||||
>
|
||||
Get Your API Key Here →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id='edit_api_key'
|
||||
type='password'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,746 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Bolt,
|
||||
Copy,
|
||||
KeyRound,
|
||||
RefreshCcw,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ConfigurationService } from '@/lib/api/services/configuration';
|
||||
|
||||
type NodeInfo = {
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
npub?: string | null;
|
||||
mints: string[];
|
||||
http_url?: string | null;
|
||||
onion_url?: string | null;
|
||||
};
|
||||
|
||||
type WalletSnapshot = {
|
||||
apiKey: string;
|
||||
balanceMsats: number;
|
||||
reservedMsats: number;
|
||||
};
|
||||
|
||||
type RefundReceipt = {
|
||||
token?: string;
|
||||
recipient?: string;
|
||||
sats?: string;
|
||||
msats?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_BASE_URL = 'http://127.0.0.1:8000';
|
||||
|
||||
async function fetchNodeInfo(baseUrl: string): Promise<NodeInfo> {
|
||||
const response = await fetch(`${baseUrl}/v1/info`, {
|
||||
cache: 'no-store',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Unable to load node info');
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as NodeInfo;
|
||||
return {
|
||||
...payload,
|
||||
mints: Array.isArray(payload.mints) ? payload.mints : [],
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWalletInfo(
|
||||
baseUrl: string,
|
||||
apiKey: string
|
||||
): Promise<WalletSnapshot> {
|
||||
const response = await fetch(`${baseUrl}/v1/balance/info`, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Unable to load wallet info');
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
api_key: string;
|
||||
balance: number;
|
||||
reserved?: number;
|
||||
};
|
||||
|
||||
return {
|
||||
apiKey: payload.api_key || apiKey,
|
||||
balanceMsats: payload.balance ?? 0,
|
||||
reservedMsats: payload.reserved ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
return trimmed.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function formatMsats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(msats);
|
||||
}
|
||||
|
||||
function formatSats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
|
||||
}
|
||||
|
||||
export function CheatSheet(): JSX.Element {
|
||||
const [baseUrl, setBaseUrl] = useState(() =>
|
||||
typeof window === 'undefined' ? '' : ConfigurationService.getLocalBaseUrl()
|
||||
);
|
||||
const [initialToken, setInitialToken] = useState('');
|
||||
const [topupToken, setTopupToken] = useState('');
|
||||
const [apiKeyInput, setApiKeyInput] = useState('');
|
||||
const [walletInfo, setWalletInfo] = useState<WalletSnapshot | null>(null);
|
||||
const [refundReceipt, setRefundReceipt] = useState<RefundReceipt | null>(null);
|
||||
const [isCreatingKey, setIsCreatingKey] = useState(false);
|
||||
const [isTopupLoading, setIsTopupLoading] = useState(false);
|
||||
const [isRefunding, setIsRefunding] = useState(false);
|
||||
const [isSyncingBalance, setIsSyncingBalance] = useState(false);
|
||||
const [hasInteractedCreate, setHasInteractedCreate] = useState(false);
|
||||
const [hasInteractedManage, setHasInteractedManage] = useState(false);
|
||||
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!baseUrl && typeof window !== 'undefined') {
|
||||
setBaseUrl(ConfigurationService.getLocalBaseUrl());
|
||||
}
|
||||
}, [baseUrl]);
|
||||
|
||||
const normalizedBaseUrl = useMemo(
|
||||
() => normalizeBaseUrl(baseUrl) || DEFAULT_BASE_URL,
|
||||
[baseUrl]
|
||||
);
|
||||
|
||||
const activeApiKey = apiKeyInput.trim();
|
||||
|
||||
const {
|
||||
data: nodeInfo,
|
||||
isLoading: isInfoLoading,
|
||||
isError: isInfoError,
|
||||
refetch: refetchNodeInfo,
|
||||
} = useQuery({
|
||||
queryKey: ['node-info', normalizedBaseUrl],
|
||||
queryFn: () => fetchNodeInfo(normalizedBaseUrl),
|
||||
enabled: Boolean(normalizedBaseUrl),
|
||||
refetchInterval: 300_000,
|
||||
staleTime: 120_000,
|
||||
});
|
||||
|
||||
const handleCopy = useCallback(async (value: string): Promise<void> => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard) {
|
||||
toast.error('Clipboard API unavailable');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success('Copied to clipboard');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Unable to copy');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCreateKey = useCallback(async (): Promise<void> => {
|
||||
if (!initialToken.trim()) {
|
||||
toast.error('Cashu token required');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingKey(true);
|
||||
setRefundReceipt(null);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
initial_balance_token: initialToken.trim(),
|
||||
});
|
||||
const response = await fetch(
|
||||
`${normalizedBaseUrl}/v1/balance/create?${params.toString()}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Failed to create API key');
|
||||
}
|
||||
const payload = (await response.json()) as {
|
||||
api_key: string;
|
||||
balance: number;
|
||||
};
|
||||
const snapshot: WalletSnapshot = {
|
||||
apiKey: payload.api_key,
|
||||
balanceMsats: payload.balance ?? 0,
|
||||
reservedMsats: 0,
|
||||
};
|
||||
setApiKeyInput(snapshot.apiKey);
|
||||
setWalletInfo(snapshot);
|
||||
setInitialToken('');
|
||||
toast.success('API key ready');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to create API key'
|
||||
);
|
||||
} finally {
|
||||
setIsCreatingKey(false);
|
||||
}
|
||||
}, [initialToken, normalizedBaseUrl]);
|
||||
|
||||
const handleSyncBalance = useCallback(async (): Promise<void> => {
|
||||
if (!activeApiKey) {
|
||||
toast.error('Paste an API key first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSyncingBalance(true);
|
||||
try {
|
||||
const snapshot = await fetchWalletInfo(normalizedBaseUrl, activeApiKey);
|
||||
setWalletInfo(snapshot);
|
||||
toast.success('Balance synced');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to sync balance'
|
||||
);
|
||||
} finally {
|
||||
setIsSyncingBalance(false);
|
||||
}
|
||||
}, [activeApiKey, normalizedBaseUrl]);
|
||||
|
||||
const handleTopup = useCallback(async (): Promise<void> => {
|
||||
if (!activeApiKey) {
|
||||
toast.error('Paste an API key first');
|
||||
return;
|
||||
}
|
||||
if (!topupToken.trim()) {
|
||||
toast.error('Cashu token required for top-up');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTopupLoading(true);
|
||||
setRefundReceipt(null);
|
||||
try {
|
||||
const response = await fetch(`${normalizedBaseUrl}/v1/balance/topup`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${activeApiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ cashu_token: topupToken.trim() }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Failed to top up');
|
||||
}
|
||||
const payload = (await response.json()) as { msats: number };
|
||||
toast.success(`Added ${formatSats(payload.msats)} sats`);
|
||||
setTopupToken('');
|
||||
const snapshot = await fetchWalletInfo(normalizedBaseUrl, activeApiKey);
|
||||
setWalletInfo(snapshot);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(error instanceof Error ? error.message : 'Top-up failed');
|
||||
} finally {
|
||||
setIsTopupLoading(false);
|
||||
}
|
||||
}, [activeApiKey, normalizedBaseUrl, topupToken]);
|
||||
|
||||
const handleRefund = useCallback(async (): Promise<void> => {
|
||||
if (!activeApiKey) {
|
||||
toast.error('Paste an API key first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRefunding(true);
|
||||
try {
|
||||
const response = await fetch(`${normalizedBaseUrl}/v1/balance/refund`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${activeApiKey}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Refund failed');
|
||||
}
|
||||
const payload = (await response.json()) as RefundReceipt;
|
||||
setRefundReceipt(payload);
|
||||
setWalletInfo(null);
|
||||
toast.success('Refund requested');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(error instanceof Error ? error.message : 'Refund failed');
|
||||
} finally {
|
||||
setIsRefunding(false);
|
||||
}
|
||||
}, [activeApiKey, normalizedBaseUrl]);
|
||||
|
||||
const handleRefreshInfo = useCallback(async (): Promise<void> => {
|
||||
const result = await refetchNodeInfo();
|
||||
if (result.error) {
|
||||
toast.error('Unable to refresh node info');
|
||||
} else {
|
||||
toast.success('Node info refreshed');
|
||||
}
|
||||
}, [refetchNodeInfo]);
|
||||
|
||||
const curlSnippet = useMemo(() => {
|
||||
const keyPreview = activeApiKey || 'YOUR_API_KEY';
|
||||
return [
|
||||
`curl -X POST "${normalizedBaseUrl}/v1/chat/completions"`,
|
||||
` -H "Authorization: Bearer ${keyPreview}"`,
|
||||
' -H "Content-Type: application/json"',
|
||||
" -d '{",
|
||||
' "model": "openai/gpt-4o-mini",',
|
||||
' "messages": [',
|
||||
' {"role":"system","content":"You are Routstr."},',
|
||||
' {"role":"user","content":"Ping the node"}',
|
||||
' ]',
|
||||
" }'",
|
||||
].join('\n');
|
||||
}, [activeApiKey, normalizedBaseUrl]);
|
||||
|
||||
const showCreateDetails =
|
||||
hasInteractedCreate || initialToken.trim().length > 0;
|
||||
const showManageDetails = hasInteractedManage || Boolean(walletInfo);
|
||||
const showTopupDetails =
|
||||
hasInteractedTopup || topupToken.trim().length > 0;
|
||||
const refundToken = refundReceipt?.token ?? null;
|
||||
const canTopup = Boolean(activeApiKey);
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-gradient-to-b from-background via-background to-muted'>
|
||||
<main className='mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-10 sm:px-6 lg:px-8'>
|
||||
<section className='relative space-y-3 text-center md:text-left'>
|
||||
<div className='absolute right-0 top-0 hidden md:block'>
|
||||
<Button asChild size='sm' className='px-3 text-xs'>
|
||||
<a href='/login'>Admin</a>
|
||||
</Button>
|
||||
</div>
|
||||
<div className='inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] uppercase tracking-wider text-muted-foreground'>
|
||||
<Bolt className='h-4 w-4 text-primary' />
|
||||
Routstr cheat sheet
|
||||
</div>
|
||||
<h1 className='text-3xl font-semibold tracking-tight sm:text-4xl'>
|
||||
Node Identity and Cheat Sheet
|
||||
</h1>
|
||||
<div className='md:hidden'>
|
||||
<Button asChild size='sm' className='w-full sm:w-auto'>
|
||||
<a href='/login'>Admin</a>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className='grid gap-4 lg:grid-cols-2'>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-start justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||
<ShieldCheck className='h-4 w-4 text-primary' />
|
||||
Node identity
|
||||
</CardTitle>
|
||||
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
|
||||
/v1/info snapshot
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='gap-1 text-xs'
|
||||
onClick={handleRefreshInfo}
|
||||
disabled={isInfoLoading}
|
||||
>
|
||||
<RefreshCcw className='h-4 w-4' />
|
||||
Refresh
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{isInfoLoading && (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Loading node profile…
|
||||
</p>
|
||||
)}
|
||||
{isInfoError && !isInfoLoading && (
|
||||
<p className='text-destructive text-sm'>
|
||||
Unable to reach /v1/info at {normalizedBaseUrl}
|
||||
</p>
|
||||
)}
|
||||
{nodeInfo && (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-2xl font-medium'>{nodeInfo.name}</p>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{nodeInfo.description}
|
||||
</p>
|
||||
</div>
|
||||
<dl className='grid gap-4 sm:grid-cols-2'>
|
||||
<div>
|
||||
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
|
||||
Version
|
||||
</dt>
|
||||
<dd className='text-base font-medium'>
|
||||
{nodeInfo.version}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
|
||||
HTTP
|
||||
</dt>
|
||||
<dd className='text-base font-medium break-all'>
|
||||
{nodeInfo.http_url || normalizedBaseUrl}
|
||||
</dd>
|
||||
</div>
|
||||
{nodeInfo.onion_url && (
|
||||
<div className='sm:col-span-2'>
|
||||
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
|
||||
Onion
|
||||
</dt>
|
||||
<dd className='text-base font-medium break-all'>
|
||||
{nodeInfo.onion_url}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{nodeInfo.npub && (
|
||||
<div className='sm:col-span-2'>
|
||||
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
|
||||
npub
|
||||
</dt>
|
||||
<dd className='flex items-center gap-2 break-all text-sm font-mono'>
|
||||
{nodeInfo.npub}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={() => handleCopy(nodeInfo.npub ?? '')}
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
|
||||
Cashu mints
|
||||
</p>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{nodeInfo.mints.length ? (
|
||||
nodeInfo.mints.map((mint) => (
|
||||
<Badge
|
||||
key={mint}
|
||||
variant='secondary'
|
||||
className='font-mono text-xs'
|
||||
>
|
||||
{mint}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
No mint list published
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between'>
|
||||
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||
<Terminal className='h-4 w-4 text-primary' />
|
||||
Quick docs
|
||||
</CardTitle>
|
||||
<span className='text-xs uppercase tracking-wide text-muted-foreground'>
|
||||
curl-ready
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
value={baseUrl}
|
||||
placeholder={normalizedBaseUrl}
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
className='text-sm'
|
||||
/>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-9 w-9'
|
||||
onClick={() => handleCopy(normalizedBaseUrl)}
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='rounded-lg bg-muted p-4 font-mono text-sm leading-6'>
|
||||
<pre className='whitespace-pre-wrap break-all'>{curlSnippet}</pre>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='secondary'
|
||||
className='gap-2'
|
||||
onClick={() => handleCopy(curlSnippet)}
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
Copy curl
|
||||
</Button>
|
||||
<Button variant='outline' asChild className='gap-2'>
|
||||
<a
|
||||
href='https://docs.routstr.com'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
<Terminal className='h-4 w-4' />
|
||||
Full docs
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader className='space-y-1'>
|
||||
<CardTitle className='flex items-center gap-2 text-xl'>
|
||||
<KeyRound className='h-5 w-5 text-primary' />
|
||||
API key workflow
|
||||
</CardTitle>
|
||||
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
|
||||
Sections expand as soon as you interact
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
<section className='space-y-2'>
|
||||
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
|
||||
<span>1 · Create key</span>
|
||||
{showCreateDetails && (
|
||||
<span className='text-primary'>Cashu token detected</span>
|
||||
)}
|
||||
</header>
|
||||
<Textarea
|
||||
value={initialToken}
|
||||
onChange={(event) => setInitialToken(event.target.value)}
|
||||
placeholder='cashuA1...'
|
||||
rows={showCreateDetails ? 4 : 2}
|
||||
className='font-mono text-sm transition-all duration-200'
|
||||
onFocus={() => setHasInteractedCreate(true)}
|
||||
/>
|
||||
{showCreateDetails && (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
onClick={handleCreateKey}
|
||||
disabled={isCreatingKey}
|
||||
className='gap-2'
|
||||
>
|
||||
{isCreatingKey ? 'Creating…' : 'Create API key'}
|
||||
</Button>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
Redeems instantly and returns <code>sk-</code> key.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className='space-y-2'>
|
||||
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
|
||||
<span>2 · Manage key</span>
|
||||
{walletInfo && (
|
||||
<span className='text-primary'>
|
||||
{formatSats(walletInfo.balanceMsats)} sats
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
<div className='flex flex-col gap-2 sm:flex-row'>
|
||||
<Input
|
||||
value={apiKeyInput}
|
||||
onChange={(event) => {
|
||||
setApiKeyInput(event.target.value);
|
||||
setWalletInfo(null);
|
||||
}}
|
||||
placeholder='sk-...'
|
||||
className='font-mono text-sm'
|
||||
onFocus={() => setHasInteractedManage(true)}
|
||||
/>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-10 w-10'
|
||||
onClick={() => handleCopy(activeApiKey)}
|
||||
disabled={!activeApiKey}
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='gap-1'
|
||||
onClick={handleSyncBalance}
|
||||
disabled={isSyncingBalance || !activeApiKey}
|
||||
>
|
||||
<RefreshCcw className='h-4 w-4' />
|
||||
Sync
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{showManageDetails && (
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-[0.65rem] uppercase tracking-wide text-muted-foreground'>
|
||||
Spendable
|
||||
</p>
|
||||
<p className='text-xl font-semibold'>
|
||||
{walletInfo
|
||||
? `${formatSats(walletInfo.balanceMsats)} sats`
|
||||
: '—'}
|
||||
</p>
|
||||
{walletInfo && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{formatMsats(walletInfo.balanceMsats)} msats
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-[0.65rem] uppercase tracking-wide text-muted-foreground'>
|
||||
Reserved
|
||||
</p>
|
||||
<p className='text-xl font-semibold'>
|
||||
{walletInfo
|
||||
? `${formatSats(walletInfo.reservedMsats)} sats`
|
||||
: '—'}
|
||||
</p>
|
||||
{walletInfo && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{formatMsats(walletInfo.reservedMsats)} msats
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className='space-y-2'>
|
||||
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
|
||||
<span>3 · Top up</span>
|
||||
{showTopupDetails && (
|
||||
<span className={canTopup ? 'text-primary' : 'text-destructive'}>
|
||||
{canTopup ? 'Ready to redeem' : 'Paste API key first'}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
<Textarea
|
||||
value={topupToken}
|
||||
onChange={(event) => setTopupToken(event.target.value)}
|
||||
placeholder='cashuB1...'
|
||||
rows={showTopupDetails ? 3 : 1}
|
||||
className='font-mono text-sm transition-all duration-200'
|
||||
onFocus={() => setHasInteractedTopup(true)}
|
||||
disabled={!canTopup}
|
||||
/>
|
||||
{showTopupDetails && (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
onClick={handleTopup}
|
||||
disabled={isTopupLoading || !canTopup}
|
||||
variant='outline'
|
||||
className='gap-2'
|
||||
>
|
||||
{isTopupLoading ? 'Topping up…' : 'Top up this key'}
|
||||
</Button>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
{canTopup
|
||||
? (
|
||||
<>
|
||||
Adds balance to the same <code>sk-</code> token.
|
||||
</>
|
||||
)
|
||||
: 'Enter your sk- key above to unlock top ups.'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className='space-y-2'>
|
||||
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
|
||||
<span>4 · Refund</span>
|
||||
{refundReceipt && <span className='text-primary'>Done</span>}
|
||||
</header>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
onClick={handleRefund}
|
||||
disabled={isRefunding}
|
||||
variant='destructive'
|
||||
className='gap-2'
|
||||
>
|
||||
{isRefunding ? 'Processing…' : 'Refund remaining balance'}
|
||||
</Button>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
Burns the key and returns a fresh Cashu token.
|
||||
</span>
|
||||
</div>
|
||||
{refundToken && (
|
||||
<div className='space-y-2 rounded-lg border bg-muted/30 p-4'>
|
||||
<div className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
|
||||
<span>Cashu refund token</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='gap-1 text-xs'
|
||||
onClick={() => handleCopy(refundToken)}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={refundToken}
|
||||
readOnly
|
||||
rows={4}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -64,6 +64,11 @@ class ApiClient {
|
||||
data,
|
||||
config
|
||||
);
|
||||
console.log(`POST response from ${endpoint}:`, {
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
headers: response.headers,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.handleAuthError(error);
|
||||
|
||||
@@ -6,6 +6,10 @@ export const ProviderTypeSchema = z.object({
|
||||
name: z.string(),
|
||||
default_base_url: z.string(),
|
||||
fixed_base_url: z.boolean(),
|
||||
platform_url: z.string().nullable(),
|
||||
can_create_account: z.boolean(),
|
||||
can_topup: z.boolean(),
|
||||
can_show_balance: z.boolean(),
|
||||
});
|
||||
|
||||
export const UpstreamProviderSchema = z.object({
|
||||
@@ -801,6 +805,54 @@ export class AdminService {
|
||||
'/admin/api/temporary-balances'
|
||||
);
|
||||
}
|
||||
|
||||
static async createProviderAccountByType(
|
||||
providerType: string
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
account_data: Record<string, unknown>;
|
||||
message: string;
|
||||
}> {
|
||||
return await apiClient.post<{
|
||||
ok: boolean;
|
||||
account_data: Record<string, unknown>;
|
||||
message: string;
|
||||
}>('/admin/api/upstream-providers/create-account', {
|
||||
provider_type: providerType,
|
||||
});
|
||||
}
|
||||
|
||||
static async initiateProviderTopup(
|
||||
providerId: number,
|
||||
amount: number
|
||||
): Promise<{ ok: boolean; topup_data: Record<string, unknown>; message: string }> {
|
||||
return await apiClient.post<{
|
||||
ok: boolean;
|
||||
topup_data: Record<string, unknown>;
|
||||
message: string;
|
||||
}>(`/admin/api/upstream-providers/${providerId}/topup`, {
|
||||
amount: amount,
|
||||
});
|
||||
}
|
||||
|
||||
static async checkTopupStatus(
|
||||
providerId: number,
|
||||
invoiceId: string
|
||||
): Promise<{ ok: boolean; paid: boolean }> {
|
||||
return await apiClient.get<{
|
||||
ok: boolean;
|
||||
paid: boolean;
|
||||
}>(`/admin/api/upstream-providers/${providerId}/topup/${invoiceId}/status`);
|
||||
}
|
||||
|
||||
static async getProviderBalance(
|
||||
providerId: number
|
||||
): Promise<{ ok: boolean; balance_data: Record<string, unknown> }> {
|
||||
return await apiClient.get<{
|
||||
ok: boolean;
|
||||
balance_data: Record<string, unknown>;
|
||||
}>(`/admin/api/upstream-providers/${providerId}/balance`);
|
||||
}
|
||||
}
|
||||
|
||||
export const TemporaryBalanceSchema = z.object({
|
||||
|
||||
@@ -9,8 +9,10 @@ export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
const publicPaths = ['/login', '/_register', '/unauthorized'];
|
||||
const isPublicPath = publicPaths.some((path) => pathname.startsWith(path));
|
||||
const publicPaths = ['/', '/login', '/_register', '/unauthorized'];
|
||||
const isPublicPath = publicPaths.some((path) =>
|
||||
path === '/' ? pathname === '/' : pathname.startsWith(path)
|
||||
);
|
||||
|
||||
if (!isPublicPath && !ConfigurationService.isTokenValid()) {
|
||||
router.push('/login');
|
||||
|
||||
@@ -43,4 +43,3 @@ export function formatFromMsat(
|
||||
});
|
||||
return formatter.format(usd);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,4 +51,3 @@ export async function fetchBtcUsdPrice(): Promise<number | null> {
|
||||
export function btcToSatsRate(btcUsdPrice: number): number {
|
||||
return btcUsdPrice / 100_000_000;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,4 +14,3 @@ export function getDisplayUnitLabel(unit: DisplayUnit): string {
|
||||
return unit;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+1602
-1675
File diff suppressed because it is too large
Load Diff
+2
-4
@@ -15,7 +15,6 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@gandlaf21/cashu-tools": "^2.0.1",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.10",
|
||||
"@radix-ui/react-avatar": "^1.1.6",
|
||||
@@ -43,14 +42,13 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.3",
|
||||
"@tanstack/react-query": "^5.74.4",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.8.4",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lnurl-pay": "^2.3.0",
|
||||
"lucide-react": "^0.501.0",
|
||||
"next": "15.3.1",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -84,4 +82,4 @@
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-6620
File diff suppressed because it is too large
Load Diff
@@ -808,6 +808,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073 },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100 },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079 },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997 },
|
||||
@@ -817,6 +819,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142 },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846 },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899 },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814 },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073 },
|
||||
@@ -826,6 +830,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759 },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685 },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346 },
|
||||
@@ -833,6 +839,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659 },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355 },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512 },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425 },
|
||||
]
|
||||
|
||||
@@ -1414,6 +1422,93 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589 },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964 },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981 },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012 },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132 },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804 },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553 },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477 },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406 },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218 },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564 },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248 },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043 },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998 },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834 },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531 },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186 },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482 },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092 },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158 },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001 },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146 },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864 },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045 },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639 },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839 },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505 },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654 },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
@@ -1783,7 +1878,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "routstr"
|
||||
version = "0.2.0"
|
||||
version = "0.2.0c0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@@ -1795,6 +1890,7 @@ dependencies = [
|
||||
{ name = "marshmallow" },
|
||||
{ name = "mdurl" },
|
||||
{ name = "nostr" },
|
||||
{ name = "pillow" },
|
||||
{ name = "python-json-logger" },
|
||||
{ name = "secp256k1" },
|
||||
{ name = "sqlmodel" },
|
||||
@@ -1827,6 +1923,7 @@ requires-dist = [
|
||||
{ name = "marshmallow", specifier = ">=3.13,<4.0" },
|
||||
{ name = "mdurl", specifier = "==0.1.2" },
|
||||
{ name = "nostr", specifier = ">=0.0.2" },
|
||||
{ name = "pillow", specifier = ">=10.0.0" },
|
||||
{ name = "python-json-logger", specifier = ">=2.0.0" },
|
||||
{ name = "secp256k1", git = "https://github.com/saschanaz/secp256k1-py?branch=upgrade060" },
|
||||
{ name = "sqlmodel", specifier = ">=0.0.24" },
|
||||
|
||||
+1
Submodule vendor/cdk added at 52d796e9fe
+1
Submodule vendor/cdk-python added at 915c6966b0
Reference in New Issue
Block a user