mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-01 16:16:14 +00:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bcf7bb948 | ||
|
|
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:
|
||||
|
||||
+4
-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)
|
||||
|
||||
@@ -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,31 @@
|
||||
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 .xai import XAIUpstreamProvider
|
||||
|
||||
upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
AnthropicUpstreamProvider,
|
||||
AzureUpstreamProvider,
|
||||
FireworksUpstreamProvider,
|
||||
GenericUpstreamProvider,
|
||||
GroqUpstreamProvider,
|
||||
OllamaUpstreamProvider,
|
||||
OpenAIUpstreamProvider,
|
||||
OpenRouterUpstreamProvider,
|
||||
PerplexityUpstreamProvider,
|
||||
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,10 +1,11 @@
|
||||
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
|
||||
@@ -13,6 +14,10 @@ from fastapi.responses import Response, StreamingResponse
|
||||
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 +30,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 +37,15 @@ from ..wallet import recieve_token, send_token
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class UpstreamProvider:
|
||||
class BaseUpstreamProvider:
|
||||
"""Provider for forwarding requests to an upstream AI service API."""
|
||||
|
||||
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 +64,39 @@ 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
|
||||
"""
|
||||
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,
|
||||
}
|
||||
|
||||
def prepare_headers(self, request_headers: dict) -> dict:
|
||||
"""Prepare headers for upstream request by removing proxy-specific headers and adding authentication.
|
||||
|
||||
@@ -163,7 +203,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 +212,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 +1666,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 +1689,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 +1698,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 +1805,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__},
|
||||
)
|
||||
|
||||
@@ -1702,111 +1832,3 @@ class UpstreamProvider:
|
||||
Model object or None if not found
|
||||
"""
|
||||
return self._models_by_id.get(model_id)
|
||||
|
||||
|
||||
class OpenAIUpstreamProvider(UpstreamProvider):
|
||||
"""Upstream provider specifically configured for OpenAI API."""
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
self.upstream_name = "openai"
|
||||
super().__init__(
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Strip 'openai/' prefix for OpenAI API compatibility."""
|
||||
return model_id.removeprefix("openai/")
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch OpenAI models from OpenRouter API filtered by openai source."""
|
||||
models_data = await async_fetch_openrouter_models(source_filter="openai")
|
||||
return [Model(**model) for model in models_data] # type: ignore
|
||||
|
||||
|
||||
class AnthropicUpstreamProvider(UpstreamProvider):
|
||||
"""Upstream provider specifically configured for Anthropic API."""
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
self.upstream_name = "anthropic"
|
||||
super().__init__(
|
||||
base_url="https://api.anthropic.com/v1",
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Strip 'anthropic/' prefix for Anthropic API compatibility."""
|
||||
return model_id.removeprefix("anthropic/")
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch Anthropic models from OpenRouter API filtered by anthropic source."""
|
||||
models_data = await async_fetch_openrouter_models(source_filter="anthropic")
|
||||
return [Model(**model) for model in models_data] # type: ignore
|
||||
|
||||
|
||||
class AzureUpstreamProvider(UpstreamProvider):
|
||||
"""Upstream provider specifically configured for Azure OpenAI Service."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
api_version: str,
|
||||
provider_fee: float = 1.01,
|
||||
):
|
||||
"""Initialize Azure provider with API key and version.
|
||||
|
||||
Args:
|
||||
base_url: Azure OpenAI endpoint base URL
|
||||
api_key: Azure OpenAI API key for authentication
|
||||
api_version: Azure OpenAI API version (e.g., "2024-02-15-preview")
|
||||
provider_fee: Provider fee multiplier (default 1.01 for 1% fee)
|
||||
"""
|
||||
super().__init__(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
self.api_version = api_version
|
||||
|
||||
def prepare_params(
|
||||
self, path: str, query_params: Mapping[str, str] | None
|
||||
) -> Mapping[str, str]:
|
||||
"""Prepare query parameters for Azure OpenAI, adding API version.
|
||||
|
||||
Args:
|
||||
path: Request path
|
||||
query_params: Original query parameters from the client
|
||||
|
||||
Returns:
|
||||
Query parameters dict with Azure API version added for chat completions
|
||||
"""
|
||||
params = dict(query_params or {})
|
||||
if path.endswith("chat/completions"):
|
||||
params["api-version"] = self.api_version
|
||||
return params
|
||||
|
||||
|
||||
class OpenRouterUpstreamProvider(UpstreamProvider):
|
||||
"""Upstream provider specifically configured for OpenRouter API."""
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.06):
|
||||
"""Initialize OpenRouter provider with API key.
|
||||
|
||||
Args:
|
||||
api_key: OpenRouter API key for authentication
|
||||
provider_fee: Provider fee multiplier (default 1.06 for 6% fee)
|
||||
"""
|
||||
self.upstream_name = "openrouter"
|
||||
super().__init__(
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch all OpenRouter models."""
|
||||
models_data = await async_fetch_openrouter_models()
|
||||
return [Model(**model) for model in models_data] # type: ignore
|
||||
@@ -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,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>
|
||||
|
||||
+91
-42
@@ -194,6 +194,11 @@ 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 toggleProviderExpansion = (providerId: number) => {
|
||||
const newExpanded = new Set(expandedProviders);
|
||||
if (newExpanded.has(providerId)) {
|
||||
@@ -277,11 +282,27 @@ 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>
|
||||
{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'
|
||||
@@ -453,7 +474,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 +517,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 +529,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 +561,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 +571,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 +614,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 +703,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({
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,6 +6,7 @@ export const ProviderTypeSchema = z.object({
|
||||
name: z.string(),
|
||||
default_base_url: z.string(),
|
||||
fixed_base_url: z.boolean(),
|
||||
platform_url: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const UpstreamProviderSchema = z.object({
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user