mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-09 02:54:37 +00:00
Merge branch 'main' into cursor/discover-and-announce-routstr-providers-3b9a
This commit is contained in:
+35
-32
@@ -1,40 +1,43 @@
|
||||
NAME = "Your Routstr Proxy Name"
|
||||
DESCRIPTION = "A short Description"
|
||||
# Core Configuration
|
||||
UPSTREAM_BASE_URL=https://api.openai.com/v1
|
||||
UPSTREAM_API_KEY=your-upstream-api-key
|
||||
ADMIN_PASSWORD=secure-admin-password
|
||||
|
||||
# Any openai-compatible api endpoint
|
||||
UPSTREAM_BASE_URL="https://api.openai.com/v1"
|
||||
UPSTREAM_API_KEY="sk-21212121212121212121212121212121"
|
||||
# UPSTREAM_PROVIDER_FEE=1 # 1 = no fees, 1.05 = 5% fees
|
||||
# Database
|
||||
DATABASE_URL=sqlite+aiosqlite:///keys.db
|
||||
|
||||
# Lightning address used to receive funds
|
||||
RECEIVE_LN_ADDRESS="shroominic@walletofsatoshi.com"
|
||||
# Node Information
|
||||
NAME=My Routstr Node
|
||||
DESCRIPTION=Fast AI API access with Bitcoin payments
|
||||
NPUB=npub1...
|
||||
HTTP_URL=https://api.mynode.com
|
||||
ONION_URL=http://mynode.onion
|
||||
|
||||
# When your cashu balance reaches this number of sats, send the funds to RECEIVE_LN_ADDRESS.
|
||||
MINIMUM_PAYOUT = "100"
|
||||
# Cashu Configuration
|
||||
CASHU_MINTS=https://mint.minibits.cash/Bitcoin
|
||||
RECEIVE_LN_ADDRESS=
|
||||
|
||||
# If set to true, pricing is loaded from the file specified by MODELS_PATH
|
||||
# Defaults to "models.json" and falls back to "models.example.json" if missing
|
||||
MODEL_BASED_PRICING = "true"
|
||||
# MODELS_PATH="models.json"
|
||||
# Pricing Configuration
|
||||
MODEL_BASED_PRICING=true
|
||||
COST_PER_REQUEST=1
|
||||
COST_PER_1K_INPUT_TOKENS=0
|
||||
COST_PER_1K_OUTPUT_TOKENS=0
|
||||
EXCHANGE_FEE=1.005
|
||||
UPSTREAM_PROVIDER_FEE=1.05
|
||||
|
||||
# Costs in Sats, if MODEL_BASED_PRICING is set to false
|
||||
# COST_PER_REQUEST="10"
|
||||
# COST_PER_1K_INPUT_TOKENS = "0"
|
||||
# COST_PER_1K_OUTPUT_TOKENS = "0"
|
||||
# EXCHANGE_FEE = "1.005" # 0.5 % currency exchange fee
|
||||
# Network Configuration
|
||||
CORS_ORIGINS=*
|
||||
TOR_PROXY_URL=socks5://127.0.0.1:9050
|
||||
|
||||
# password used to log into admin interface
|
||||
# ADMIN_PASSWORD=""
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
ENABLE_CONSOLE_LOGGING=true
|
||||
|
||||
# Public Endpoint
|
||||
HTTP_URL="https://your.domain.com"
|
||||
# Model Management
|
||||
MODELS_PATH=models.json
|
||||
BASE_URL=https://openrouter.ai/api/v1
|
||||
SOURCE=
|
||||
|
||||
# Tor Endpoint (copy from docker logs)
|
||||
# ONION_URL=".onion"
|
||||
|
||||
RELAYS="wss://relay.routstr.com,wss://relay.nostr.band"
|
||||
CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org"
|
||||
|
||||
# Development
|
||||
# DEBUG=TRUE
|
||||
# LOG_LEVEL=TRACE
|
||||
# Optional Features
|
||||
PREPAID_API_KEY=
|
||||
PREPAID_BALANCE=0
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Deploy MkDocs to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "mkdocs.yml"
|
||||
- ".github/workflows/docs.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install -r docs/requirements.txt
|
||||
|
||||
- name: Build MkDocs site
|
||||
run: mkdocs build --strict --verbose
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./site
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
+1
-1
@@ -12,7 +12,7 @@ RUN apk add --no-cache \
|
||||
RUN apk add git
|
||||
|
||||
COPY uv.lock pyproject.toml ./
|
||||
COPY routstr ./routstr
|
||||
RUN mkdir -p /routstr
|
||||
|
||||
RUN uv add git+https://github.com/saschanaz/secp256k1-py.git#branch=upgrade060
|
||||
# RUN uv sync
|
||||
|
||||
@@ -244,3 +244,20 @@ profile:
|
||||
@echo "🔥 Running with profiling..."
|
||||
$(PYTHON) -m cProfile -o profile.stats -m pytest tests/integration/test_performance_load.py::TestPerformanceBaseline -v
|
||||
@echo "Profile saved to profile.stats. Use '$(PYTHON) -m pstats profile.stats' to analyze."
|
||||
|
||||
# Documentation
|
||||
docs-build:
|
||||
@echo "📚 Building documentation..."
|
||||
mkdocs build
|
||||
|
||||
docs-serve:
|
||||
@echo "📚 Serving documentation at http://localhost:8001..."
|
||||
mkdocs serve -a localhost:8001
|
||||
|
||||
docs-deploy:
|
||||
@echo "📚 Deploying documentation to GitHub Pages..."
|
||||
mkdocs gh-deploy --force
|
||||
|
||||
docs-install:
|
||||
@echo "📚 Installing documentation dependencies..."
|
||||
pip install -r docs/requirements.txt
|
||||
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build script for MkDocs documentation
|
||||
|
||||
echo "Building Routstr Core documentation..."
|
||||
|
||||
# Check if mkdocs is installed
|
||||
if ! command -v mkdocs &> /dev/null; then
|
||||
echo "MkDocs not found. Installing dependencies..."
|
||||
pip install -r docs/requirements.txt
|
||||
fi
|
||||
|
||||
# Build the documentation
|
||||
echo "Building docs..."
|
||||
mkdocs build
|
||||
|
||||
# Serve locally for preview (optional)
|
||||
if [ "$1" = "serve" ]; then
|
||||
echo "Starting documentation server at http://localhost:8001"
|
||||
mkdocs serve -a localhost:8001
|
||||
elif [ "$1" = "deploy" ]; then
|
||||
echo "Deploying to GitHub Pages..."
|
||||
mkdocs gh-deploy --force
|
||||
else
|
||||
echo "Documentation built successfully in ./site/"
|
||||
echo "Run './build-docs.sh serve' to preview locally"
|
||||
echo "Run './build-docs.sh deploy' to deploy to GitHub Pages"
|
||||
fi
|
||||
@@ -0,0 +1,567 @@
|
||||
# Custom Pricing
|
||||
|
||||
This guide covers advanced pricing strategies and customization options for Routstr Core.
|
||||
|
||||
## Pricing Models Overview
|
||||
|
||||
Routstr supports three pricing models:
|
||||
|
||||
1. **Fixed Pricing** - Simple per-request fee
|
||||
2. **Token-Based Pricing** - Charge per input/output token
|
||||
3. **Model-Based Pricing** - Dynamic pricing from models.json
|
||||
|
||||
## Model-Based Pricing
|
||||
|
||||
### Configuration
|
||||
|
||||
Enable model-based pricing:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
MODEL_BASED_PRICING=true
|
||||
MODELS_PATH=/app/config/models.json
|
||||
EXCHANGE_FEE=1.005 # 0.5% exchange fee
|
||||
UPSTREAM_PROVIDER_FEE=1.05 # 5% provider margin
|
||||
```
|
||||
|
||||
### Custom Models File
|
||||
|
||||
Create a `models.json` with your pricing:
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-4",
|
||||
"name": "GPT-4",
|
||||
"description": "Advanced reasoning model",
|
||||
"context_length": 8192,
|
||||
"pricing": {
|
||||
"prompt": "0.03", // USD per 1K tokens
|
||||
"completion": "0.06", // USD per 1K tokens
|
||||
"request": "0.0001", // Fixed per-request fee
|
||||
"image": "0", // For multimodal models
|
||||
"web_search": "0.005", // Additional features
|
||||
"internal_reasoning": "0.01"
|
||||
},
|
||||
"supported_features": [
|
||||
"function_calling",
|
||||
"vision",
|
||||
"json_mode"
|
||||
],
|
||||
"deprecation_date": null,
|
||||
"replacement_model": null
|
||||
},
|
||||
{
|
||||
"id": "custom-model",
|
||||
"name": "Custom Fine-tuned Model",
|
||||
"pricing": {
|
||||
"prompt": "0.001",
|
||||
"completion": "0.002",
|
||||
"request": "0.00005"
|
||||
},
|
||||
"minimum_charge": "0.0001" // Minimum charge per request
|
||||
}
|
||||
],
|
||||
"default_pricing": {
|
||||
"prompt": "0.002",
|
||||
"completion": "0.002",
|
||||
"request": "0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic Price Updates
|
||||
|
||||
Automatically fetch prices from providers:
|
||||
|
||||
```python
|
||||
# scripts/update_prices.py
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
|
||||
async def fetch_openrouter_models():
|
||||
"""Fetch current model pricing from OpenRouter."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"https://openrouter.ai/api/v1/models"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def update_models_json():
|
||||
"""Update local models.json with latest prices."""
|
||||
data = await fetch_openrouter_models()
|
||||
|
||||
models = []
|
||||
for model in data['data']:
|
||||
models.append({
|
||||
"id": model['id'],
|
||||
"name": model['name'],
|
||||
"pricing": {
|
||||
"prompt": model['pricing']['prompt'],
|
||||
"completion": model['pricing']['completion'],
|
||||
"request": model['pricing'].get('request', '0')
|
||||
},
|
||||
"context_length": model.get('context_length', 4096)
|
||||
})
|
||||
|
||||
with open('models.json', 'w') as f:
|
||||
json.dump({"models": models}, f, indent=2)
|
||||
|
||||
# Run periodically
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(update_models_json())
|
||||
```
|
||||
|
||||
## Token-Based Pricing
|
||||
|
||||
### Configuration
|
||||
|
||||
Set up token-based pricing:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
MODEL_BASED_PRICING=false
|
||||
COST_PER_REQUEST=1 # 1 sat base fee
|
||||
COST_PER_1K_INPUT_TOKENS=5 # 5 sats per 1K input
|
||||
COST_PER_1K_OUTPUT_TOKENS=15 # 15 sats per 1K output
|
||||
```
|
||||
|
||||
### Custom Token Counting
|
||||
|
||||
Override default token counting:
|
||||
|
||||
```python
|
||||
from tiktoken import encoding_for_model
|
||||
|
||||
class CustomTokenCounter:
|
||||
def __init__(self):
|
||||
self.encodings = {}
|
||||
|
||||
def count_tokens(
|
||||
self,
|
||||
text: str,
|
||||
model: str
|
||||
) -> int:
|
||||
"""Custom token counting logic."""
|
||||
# Cache encodings
|
||||
if model not in self.encodings:
|
||||
try:
|
||||
self.encodings[model] = encoding_for_model(model)
|
||||
except:
|
||||
# Fallback encoding
|
||||
self.encodings[model] = encoding_for_model("gpt-3.5-turbo")
|
||||
|
||||
encoding = self.encodings[model]
|
||||
|
||||
# Special handling for certain content
|
||||
if text.startswith("```"):
|
||||
# Code blocks might need special handling
|
||||
tokens = encoding.encode(text)
|
||||
return len(tokens) * 1.1 # 10% markup for code
|
||||
|
||||
return len(encoding.encode(text))
|
||||
```
|
||||
|
||||
## Advanced Pricing Strategies
|
||||
|
||||
### Time-Based Pricing
|
||||
|
||||
Implement peak/off-peak pricing:
|
||||
|
||||
```python
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
class TimeBased PricingStrategy:
|
||||
def __init__(self):
|
||||
self.timezone = pytz.timezone('US/Eastern')
|
||||
self.peak_hours = [(9, 17)] # 9 AM - 5 PM
|
||||
self.peak_multiplier = 1.5
|
||||
self.weekend_discount = 0.8
|
||||
|
||||
def get_price_multiplier(self) -> float:
|
||||
"""Calculate price multiplier based on time."""
|
||||
now = datetime.now(self.timezone)
|
||||
|
||||
# Weekend discount
|
||||
if now.weekday() >= 5: # Saturday or Sunday
|
||||
return self.weekend_discount
|
||||
|
||||
# Peak hours surcharge
|
||||
hour = now.hour
|
||||
for start, end in self.peak_hours:
|
||||
if start <= hour < end:
|
||||
return self.peak_multiplier
|
||||
|
||||
# Off-peak standard pricing
|
||||
return 1.0
|
||||
|
||||
def apply_to_cost(self, base_cost: int) -> int:
|
||||
"""Apply time-based pricing to cost."""
|
||||
multiplier = self.get_price_multiplier()
|
||||
return int(base_cost * multiplier)
|
||||
```
|
||||
|
||||
### Model-Specific Surcharges
|
||||
|
||||
Add custom fees for specific models:
|
||||
|
||||
```python
|
||||
class ModelSurchargeStrategy:
|
||||
def __init__(self):
|
||||
self.surcharges = {
|
||||
"gpt-4-turbo": 1.1, # 10% premium
|
||||
"claude-3-opus": 1.15, # 15% premium
|
||||
"dall-e-3-hd": 1.25, # 25% premium for HD
|
||||
}
|
||||
|
||||
self.discounts = {
|
||||
"gpt-3.5-turbo": 0.95, # 5% discount
|
||||
"deprecated-model": 0.8, # 20% discount
|
||||
}
|
||||
|
||||
def get_model_multiplier(self, model: str) -> float:
|
||||
"""Get price multiplier for model."""
|
||||
if model in self.surcharges:
|
||||
return self.surcharges[model]
|
||||
elif model in self.discounts:
|
||||
return self.discounts[model]
|
||||
return 1.0
|
||||
```
|
||||
|
||||
### Geographic Pricing
|
||||
|
||||
Adjust pricing based on client location:
|
||||
|
||||
```python
|
||||
import geoip2.database
|
||||
|
||||
class GeographicPricingStrategy:
|
||||
def __init__(self):
|
||||
self.reader = geoip2.database.Reader('GeoLite2-Country.mmdb')
|
||||
self.country_multipliers = {
|
||||
'US': 1.0,
|
||||
'GB': 1.0,
|
||||
'DE': 1.0,
|
||||
'IN': 0.7, # 30% discount
|
||||
'BR': 0.8, # 20% discount
|
||||
'NG': 0.6, # 40% discount
|
||||
}
|
||||
self.default_multiplier = 0.9
|
||||
|
||||
def get_country_multiplier(self, ip_address: str) -> float:
|
||||
"""Get price multiplier based on country."""
|
||||
try:
|
||||
response = self.reader.country(ip_address)
|
||||
country_code = response.country.iso_code
|
||||
return self.country_multipliers.get(
|
||||
country_code,
|
||||
self.default_multiplier
|
||||
)
|
||||
except:
|
||||
return 1.0 # Default pricing if lookup fails
|
||||
```
|
||||
|
||||
## Cost Calculation Pipeline
|
||||
|
||||
### Implementing Custom Calculator
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
class CostCalculator(ABC):
|
||||
@abstractmethod
|
||||
async def calculate(
|
||||
self,
|
||||
request_data: dict,
|
||||
usage_data: dict,
|
||||
context: dict
|
||||
) -> CostResult:
|
||||
pass
|
||||
|
||||
class CompositeCostCalculator(CostCalculator):
|
||||
"""Combine multiple pricing strategies."""
|
||||
|
||||
def __init__(self):
|
||||
self.strategies = [
|
||||
BaseCostCalculator(),
|
||||
TimeBasedPricingStrategy(),
|
||||
ModelSurchargeStrategy(),
|
||||
GeographicPricingStrategy()
|
||||
]
|
||||
|
||||
async def calculate(
|
||||
self,
|
||||
request_data: dict,
|
||||
usage_data: dict,
|
||||
context: dict
|
||||
) -> CostResult:
|
||||
# Start with base cost
|
||||
base_cost = await self.strategies[0].calculate(
|
||||
request_data, usage_data, context
|
||||
)
|
||||
|
||||
# Apply each strategy
|
||||
final_cost = base_cost.total_msats
|
||||
breakdown = {"base": base_cost.total_msats}
|
||||
|
||||
for strategy in self.strategies[1:]:
|
||||
multiplier = await strategy.get_multiplier(context)
|
||||
adjustment = final_cost * (multiplier - 1)
|
||||
final_cost += adjustment
|
||||
breakdown[strategy.__class__.__name__] = adjustment
|
||||
|
||||
return CostResult(
|
||||
total_msats=int(final_cost),
|
||||
breakdown=breakdown
|
||||
)
|
||||
```
|
||||
|
||||
### Integration with Routstr
|
||||
|
||||
```python
|
||||
# In routstr/payment/cost_calculation.py
|
||||
async def calculate_request_cost(
|
||||
model: str,
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
request_type: str,
|
||||
context: dict
|
||||
) -> CostData:
|
||||
"""Enhanced cost calculation with custom strategies."""
|
||||
|
||||
# Use custom calculator if configured
|
||||
if os.getenv("USE_CUSTOM_PRICING", "false").lower() == "true":
|
||||
calculator = CompositeCostCalculator()
|
||||
result = await calculator.calculate(
|
||||
request_data={
|
||||
"model": model,
|
||||
"type": request_type
|
||||
},
|
||||
usage_data={
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens
|
||||
},
|
||||
context=context
|
||||
)
|
||||
return result
|
||||
|
||||
# Fall back to standard calculation
|
||||
return standard_calculate_cost(...)
|
||||
```
|
||||
|
||||
## Monitoring Pricing
|
||||
|
||||
### Price Analytics
|
||||
|
||||
Track pricing effectiveness:
|
||||
|
||||
```python
|
||||
class PricingAnalytics:
|
||||
async def analyze_pricing(
|
||||
self,
|
||||
start_date: datetime,
|
||||
end_date: datetime
|
||||
):
|
||||
"""Analyze pricing performance."""
|
||||
# Average cost per request by model
|
||||
model_costs = await self.get_average_costs_by_model(
|
||||
start_date, end_date
|
||||
)
|
||||
|
||||
# Revenue by pricing strategy
|
||||
strategy_revenue = await self.get_revenue_by_strategy(
|
||||
start_date, end_date
|
||||
)
|
||||
|
||||
# Price elasticity
|
||||
elasticity = await self.calculate_price_elasticity()
|
||||
|
||||
return {
|
||||
"model_costs": model_costs,
|
||||
"strategy_revenue": strategy_revenue,
|
||||
"price_elasticity": elasticity,
|
||||
"recommendations": self.generate_recommendations(
|
||||
model_costs, elasticity
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### A/B Testing Prices
|
||||
|
||||
Test different pricing strategies:
|
||||
|
||||
```python
|
||||
class PricingExperiment:
|
||||
def __init__(self):
|
||||
self.experiments = {
|
||||
"exp_001": {
|
||||
"name": "10% discount test",
|
||||
"group_a": {"multiplier": 1.0},
|
||||
"group_b": {"multiplier": 0.9},
|
||||
"allocation": 0.5 # 50/50 split
|
||||
}
|
||||
}
|
||||
|
||||
def assign_group(self, api_key_id: int) -> str:
|
||||
"""Assign API key to experiment group."""
|
||||
# Consistent assignment based on key ID
|
||||
import hashlib
|
||||
hash_value = int(hashlib.md5(
|
||||
str(api_key_id).encode()
|
||||
).hexdigest()[:8], 16)
|
||||
|
||||
return "group_b" if (hash_value % 100) < 50 else "group_a"
|
||||
|
||||
def get_experiment_multiplier(
|
||||
self,
|
||||
api_key_id: int,
|
||||
experiment_id: str
|
||||
) -> float:
|
||||
"""Get price multiplier for experiment."""
|
||||
experiment = self.experiments.get(experiment_id)
|
||||
if not experiment:
|
||||
return 1.0
|
||||
|
||||
group = self.assign_group(api_key_id)
|
||||
return experiment[group]["multiplier"]
|
||||
```
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### Enterprise Pricing
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-4-enterprise",
|
||||
"name": "GPT-4 Enterprise",
|
||||
"pricing": {
|
||||
"prompt": "0.02",
|
||||
"completion": "0.04"
|
||||
},
|
||||
"minimum_commitment": "1000", // $1000/month minimum
|
||||
"sla": {
|
||||
"uptime": "99.9%",
|
||||
"support_response": "1 hour",
|
||||
"dedicated_capacity": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"enterprise_features": {
|
||||
"priority_queue": true,
|
||||
"custom_models": true,
|
||||
"audit_logs": true,
|
||||
"sso": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Budget-Friendly Options
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-3.5-turbo-budget",
|
||||
"name": "GPT-3.5 Turbo Budget",
|
||||
"pricing": {
|
||||
"prompt": "0.0005",
|
||||
"completion": "0.001"
|
||||
},
|
||||
"restrictions": {
|
||||
"max_tokens_per_request": 1000,
|
||||
"requests_per_minute": 10,
|
||||
"peak_hours_blocked": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"prepaid_packages": [
|
||||
{
|
||||
"name": "Starter Pack",
|
||||
"price_usd": 10,
|
||||
"tokens_included": 10000000,
|
||||
"expires_days": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Price Calculation Issues
|
||||
|
||||
```python
|
||||
# Debug pricing
|
||||
async def debug_price_calculation(
|
||||
model: str,
|
||||
tokens: dict,
|
||||
api_key_id: int
|
||||
):
|
||||
"""Debug price calculation step by step."""
|
||||
print(f"Model: {model}")
|
||||
print(f"Tokens: {tokens}")
|
||||
|
||||
# Base price
|
||||
base_price = get_model_price(model)
|
||||
print(f"Base price: {base_price}")
|
||||
|
||||
# Token cost
|
||||
token_cost = calculate_token_cost(base_price, tokens)
|
||||
print(f"Token cost: {token_cost}")
|
||||
|
||||
# Strategies
|
||||
strategies = get_active_strategies()
|
||||
for strategy in strategies:
|
||||
multiplier = await strategy.get_multiplier(api_key_id)
|
||||
print(f"{strategy.name}: {multiplier}x")
|
||||
|
||||
# Final cost
|
||||
final_cost = apply_all_strategies(token_cost, api_key_id)
|
||||
print(f"Final cost: {final_cost} msats")
|
||||
|
||||
return final_cost
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Prices Not Updating**
|
||||
- Check `MODELS_PATH` is correct
|
||||
- Verify file permissions
|
||||
- Check background task logs
|
||||
|
||||
2. **Wrong Currency Conversion**
|
||||
- Verify BTC/USD rate source
|
||||
- Check `EXCHANGE_FEE` setting
|
||||
- Monitor rate update frequency
|
||||
|
||||
3. **Discounts Not Applied**
|
||||
- Verify strategy configuration
|
||||
- Check API key metadata
|
||||
- Review transaction history
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Transparent Pricing**
|
||||
- Publish pricing clearly
|
||||
- Show cost breakdowns
|
||||
- Notify of price changes
|
||||
|
||||
2. **Fair Pricing**
|
||||
- Regular competitive analysis
|
||||
- Consider user feedback
|
||||
- Offer budget options
|
||||
|
||||
3. **Performance**
|
||||
- Cache price calculations
|
||||
- Optimize database queries
|
||||
- Monitor calculation time
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Migrations](migrations.md) - Database migration guide
|
||||
- [API Endpoints](../api/endpoints.md) - Pricing endpoints
|
||||
- [Monitoring](../user-guide/admin-dashboard.md) - Track pricing metrics
|
||||
@@ -0,0 +1,646 @@
|
||||
# Database Migrations
|
||||
|
||||
This guide covers database schema management using Alembic migrations in Routstr Core.
|
||||
|
||||
## Overview
|
||||
|
||||
Routstr uses Alembic for database migrations with these features:
|
||||
|
||||
- **Automatic migrations** on startup
|
||||
- **Version control** for schema changes
|
||||
- **Rollback capability** for safety
|
||||
- **Support for multiple databases** (SQLite, PostgreSQL)
|
||||
|
||||
## Automatic Migrations
|
||||
|
||||
### Startup Behavior
|
||||
|
||||
Migrations run automatically when Routstr starts:
|
||||
|
||||
```python
|
||||
# In routstr/core/main.py
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
logger.info("Running database migrations")
|
||||
run_migrations() # Automatic migration
|
||||
await init_db() # Initialize connection pool
|
||||
# ... rest of startup
|
||||
```
|
||||
|
||||
This ensures:
|
||||
|
||||
- ✅ Database is always up-to-date
|
||||
- ✅ No manual migration steps in production
|
||||
- ✅ Zero-downtime deployments
|
||||
- ✅ Backwards compatibility
|
||||
|
||||
### Migration Safety
|
||||
|
||||
Migrations are designed to be safe:
|
||||
|
||||
- Idempotent (can run multiple times)
|
||||
- Non-destructive by default
|
||||
- Tested before release
|
||||
- Reversible when possible
|
||||
|
||||
## Creating Migrations
|
||||
|
||||
### Auto-generating from Models
|
||||
|
||||
After modifying SQLModel classes:
|
||||
|
||||
```bash
|
||||
# Generate migration from model changes
|
||||
make db-migrate
|
||||
|
||||
# You'll be prompted for a description
|
||||
Enter migration message: Add user preferences table
|
||||
|
||||
# Review generated file
|
||||
cat migrations/versions/xxxx_add_user_preferences_table.py
|
||||
```
|
||||
|
||||
### Manual Migrations
|
||||
|
||||
For complex changes, create manually:
|
||||
|
||||
```bash
|
||||
# Create empty migration
|
||||
alembic revision -m "Complex data transformation"
|
||||
|
||||
# Edit the generated file
|
||||
vim migrations/versions/xxxx_complex_data_transformation.py
|
||||
```
|
||||
|
||||
### Migration Template
|
||||
|
||||
```python
|
||||
"""Add user preferences table
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: f6e5d4c3b2a1
|
||||
Create Date: 2024-01-15 10:30:00.123456
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
# revision identifiers
|
||||
revision = 'a1b2c3d4e5f6'
|
||||
down_revision = 'f6e5d4c3b2a1'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Apply migration."""
|
||||
# Create new table
|
||||
op.create_table(
|
||||
'userpreferences',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('api_key_id', sa.Integer(), nullable=False),
|
||||
sa.Column('theme', sa.String(), nullable=True),
|
||||
sa.Column('notifications_enabled', sa.Boolean(), default=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['api_key_id'], ['apikey.id'], )
|
||||
)
|
||||
|
||||
# Create index
|
||||
op.create_index(
|
||||
'ix_userpreferences_api_key_id',
|
||||
'userpreferences',
|
||||
['api_key_id']
|
||||
)
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Revert migration."""
|
||||
op.drop_index('ix_userpreferences_api_key_id', table_name='userpreferences')
|
||||
op.drop_table('userpreferences')
|
||||
```
|
||||
|
||||
## Common Migration Patterns
|
||||
|
||||
### Adding Columns
|
||||
|
||||
Add column with default value:
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# Add nullable column first
|
||||
op.add_column(
|
||||
'apikey',
|
||||
sa.Column('last_rotation', sa.DateTime(), nullable=True)
|
||||
)
|
||||
|
||||
# Populate existing rows
|
||||
connection = op.get_bind()
|
||||
connection.execute(
|
||||
"UPDATE apikey SET last_rotation = created_at WHERE last_rotation IS NULL"
|
||||
)
|
||||
|
||||
# Make non-nullable if needed
|
||||
op.alter_column('apikey', 'last_rotation', nullable=False)
|
||||
```
|
||||
|
||||
### Renaming Columns
|
||||
|
||||
Safe column rename:
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# SQLite doesn't support ALTER COLUMN, so we need a workaround
|
||||
with op.batch_alter_table('apikey') as batch_op:
|
||||
batch_op.alter_column('old_name', new_column_name='new_name')
|
||||
```
|
||||
|
||||
### Adding Indexes
|
||||
|
||||
Performance-improving indexes:
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# Single column index
|
||||
op.create_index(
|
||||
'ix_transaction_timestamp',
|
||||
'transaction',
|
||||
['timestamp']
|
||||
)
|
||||
|
||||
# Composite index
|
||||
op.create_index(
|
||||
'ix_transaction_key_time',
|
||||
'transaction',
|
||||
['api_key_id', 'timestamp']
|
||||
)
|
||||
|
||||
# Partial index (PostgreSQL only)
|
||||
op.create_index(
|
||||
'ix_apikey_active',
|
||||
'apikey',
|
||||
['balance'],
|
||||
postgresql_where='balance > 0'
|
||||
)
|
||||
```
|
||||
|
||||
### Data Migrations
|
||||
|
||||
Transform existing data:
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# Add new column
|
||||
op.add_column(
|
||||
'apikey',
|
||||
sa.Column('key_type', sa.String(), nullable=True)
|
||||
)
|
||||
|
||||
# Migrate data
|
||||
connection = op.get_bind()
|
||||
result = connection.execute('SELECT id, metadata FROM apikey')
|
||||
|
||||
for row in result:
|
||||
key_type = 'premium' if row.metadata.get('premium') else 'standard'
|
||||
connection.execute(
|
||||
f"UPDATE apikey SET key_type = '{key_type}' WHERE id = {row.id}"
|
||||
)
|
||||
|
||||
# Make column non-nullable
|
||||
op.alter_column('apikey', 'key_type', nullable=False)
|
||||
```
|
||||
|
||||
### Enum Types
|
||||
|
||||
Add enum column:
|
||||
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
class KeyStatus(str, Enum):
|
||||
ACTIVE = "active"
|
||||
SUSPENDED = "suspended"
|
||||
EXPIRED = "expired"
|
||||
|
||||
def upgrade():
|
||||
# Create enum type (PostgreSQL)
|
||||
key_status_enum = sa.Enum(KeyStatus, name='keystatus')
|
||||
key_status_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# Add column
|
||||
op.add_column(
|
||||
'apikey',
|
||||
sa.Column(
|
||||
'status',
|
||||
key_status_enum,
|
||||
nullable=False,
|
||||
server_default='active'
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## Database-Specific Considerations
|
||||
|
||||
### SQLite Limitations
|
||||
|
||||
SQLite has limitations requiring workarounds:
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# SQLite doesn't support ALTER COLUMN directly
|
||||
# Use batch_alter_table for compatibility
|
||||
with op.batch_alter_table('apikey') as batch_op:
|
||||
batch_op.alter_column(
|
||||
'balance',
|
||||
type_=sa.BigInteger(), # Change from Integer
|
||||
existing_type=sa.Integer()
|
||||
)
|
||||
```
|
||||
|
||||
### PostgreSQL Features
|
||||
|
||||
Leverage PostgreSQL-specific features:
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# Use JSONB for better performance
|
||||
op.add_column(
|
||||
'apikey',
|
||||
sa.Column('metadata', sa.JSON().with_variant(
|
||||
sa.dialects.postgresql.JSONB(), 'postgresql'
|
||||
))
|
||||
)
|
||||
|
||||
# Add GIN index for JSONB queries
|
||||
op.create_index(
|
||||
'ix_apikey_metadata',
|
||||
'apikey',
|
||||
['metadata'],
|
||||
postgresql_using='gin'
|
||||
)
|
||||
|
||||
# Add check constraint
|
||||
op.create_check_constraint(
|
||||
'ck_apikey_balance_positive',
|
||||
'apikey',
|
||||
'balance >= 0'
|
||||
)
|
||||
```
|
||||
|
||||
## Migration Commands
|
||||
|
||||
### Running Migrations
|
||||
|
||||
```bash
|
||||
# Apply all pending migrations
|
||||
make db-upgrade
|
||||
|
||||
# Upgrade to specific revision
|
||||
alembic upgrade a1b2c3d4e5f6
|
||||
|
||||
# Upgrade one revision
|
||||
alembic upgrade +1
|
||||
```
|
||||
|
||||
### Checking Status
|
||||
|
||||
```bash
|
||||
# Show current revision
|
||||
make db-current
|
||||
# Output: a1b2c3d4e5f6 (head)
|
||||
|
||||
# Show migration history
|
||||
make db-history
|
||||
# Output:
|
||||
# a1b2c3d4e5f6 -> b2c3d4e5f6a7 (head), Add user preferences
|
||||
# f6e5d4c3b2a1 -> a1b2c3d4e5f6, Add indexes
|
||||
# e5d4c3b2a1f6 -> f6e5d4c3b2a1, Initial schema
|
||||
```
|
||||
|
||||
### Rolling Back
|
||||
|
||||
```bash
|
||||
# Rollback one migration
|
||||
make db-downgrade
|
||||
|
||||
# Rollback to specific revision
|
||||
alembic downgrade f6e5d4c3b2a1
|
||||
|
||||
# Rollback all (dangerous!)
|
||||
alembic downgrade base
|
||||
```
|
||||
|
||||
## Testing Migrations
|
||||
|
||||
### Unit Testing
|
||||
|
||||
Test migrations in isolation:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
def test_migration_add_user_preferences():
|
||||
"""Test user preferences migration."""
|
||||
# Create test database
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
|
||||
# Run migrations up to previous version
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", str(engine.url))
|
||||
command.upgrade(alembic_cfg, "f6e5d4c3b2a1")
|
||||
|
||||
# Verify state before migration
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
assert "userpreferences" not in tables
|
||||
|
||||
# Run target migration
|
||||
command.upgrade(alembic_cfg, "a1b2c3d4e5f6")
|
||||
|
||||
# Verify state after migration
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
assert "userpreferences" in tables
|
||||
|
||||
# Check columns
|
||||
columns = {col['name'] for col in inspector.get_columns('userpreferences')}
|
||||
assert columns == {'id', 'api_key_id', 'theme', 'notifications_enabled', 'created_at'}
|
||||
|
||||
# Test downgrade
|
||||
command.downgrade(alembic_cfg, "f6e5d4c3b2a1")
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
assert "userpreferences" not in tables
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
|
||||
Test with real data:
|
||||
|
||||
```python
|
||||
async def test_migration_with_data():
|
||||
"""Test migration preserves existing data."""
|
||||
# Setup test database with data
|
||||
async with test_engine.begin() as conn:
|
||||
# Insert test data
|
||||
await conn.execute(
|
||||
"INSERT INTO apikey (key_hash, balance) VALUES ('test', 1000)"
|
||||
)
|
||||
|
||||
# Run migration
|
||||
run_migrations()
|
||||
|
||||
# Verify data integrity
|
||||
async with test_engine.connect() as conn:
|
||||
result = await conn.execute("SELECT * FROM apikey WHERE key_hash = 'test'")
|
||||
row = result.first()
|
||||
assert row.balance == 1000
|
||||
assert row.key_type == 'standard' # New column with default
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Zero-Downtime Migrations
|
||||
|
||||
Strategy for seamless updates:
|
||||
|
||||
1. **Make migrations backwards compatible**
|
||||
|
||||
```python
|
||||
# Good: Add nullable column
|
||||
op.add_column('apikey', sa.Column('new_field', sa.String(), nullable=True))
|
||||
|
||||
# Bad: Drop column immediately
|
||||
# op.drop_column('apikey', 'old_field')
|
||||
```
|
||||
|
||||
2. **Deploy in phases**
|
||||
|
||||
```bash
|
||||
# Phase 1: Deploy code that works with both schemas
|
||||
# Phase 2: Run migration
|
||||
# Phase 3: Deploy code that requires new schema
|
||||
# Phase 4: Clean up deprecated columns
|
||||
```
|
||||
|
||||
3. **Use feature flags**
|
||||
|
||||
```python
|
||||
if feature_enabled('use_new_schema'):
|
||||
# Use new column
|
||||
query = select(APIKey.new_field)
|
||||
else:
|
||||
# Use old column
|
||||
query = select(APIKey.old_field)
|
||||
```
|
||||
|
||||
### Migration Monitoring
|
||||
|
||||
Track migration execution:
|
||||
|
||||
```python
|
||||
# Add to migration
|
||||
def upgrade():
|
||||
start_time = time.time()
|
||||
logger.info(f"Starting migration {revision}")
|
||||
|
||||
try:
|
||||
# Migration logic here
|
||||
op.create_table(...)
|
||||
|
||||
duration = time.time() - start_time
|
||||
logger.info(f"Migration {revision} completed in {duration:.2f}s")
|
||||
except Exception as e:
|
||||
logger.error(f"Migration {revision} failed: {e}")
|
||||
raise
|
||||
```
|
||||
|
||||
### Backup Before Migration
|
||||
|
||||
Always backup before major changes:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# backup_before_migration.sh
|
||||
|
||||
# Backup database
|
||||
if [[ "$DATABASE_URL" == *"sqlite"* ]]; then
|
||||
cp database.db "backup_$(date +%Y%m%d_%H%M%S).db"
|
||||
else
|
||||
pg_dump $DATABASE_URL > "backup_$(date +%Y%m%d_%H%M%S).sql"
|
||||
fi
|
||||
|
||||
# Run migration
|
||||
alembic upgrade head
|
||||
|
||||
# Verify
|
||||
alembic current
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Migration Conflicts**
|
||||
|
||||
```bash
|
||||
# Multiple heads detected
|
||||
alembic heads
|
||||
# a1b2c3d4e5f6 (head)
|
||||
# b2c3d4e5f6a7 (head)
|
||||
|
||||
# Merge heads
|
||||
alembic merge -m "Merge migrations" a1b2c3 b2c3d4
|
||||
```
|
||||
|
||||
**Failed Migration**
|
||||
|
||||
```python
|
||||
# Add rollback logic
|
||||
def upgrade():
|
||||
try:
|
||||
op.create_table(...)
|
||||
except Exception as e:
|
||||
# Clean up partial changes
|
||||
op.drop_table('partial_table', checkfirst=True)
|
||||
raise
|
||||
|
||||
def downgrade():
|
||||
# Ensure clean rollback
|
||||
op.drop_table('new_table', checkfirst=True)
|
||||
```
|
||||
|
||||
**Lock Timeout**
|
||||
|
||||
```python
|
||||
# Add timeout handling
|
||||
def upgrade():
|
||||
connection = op.get_bind()
|
||||
|
||||
# Set timeout (PostgreSQL)
|
||||
connection.execute("SET lock_timeout = '10s'")
|
||||
|
||||
try:
|
||||
op.add_column(...)
|
||||
except OperationalError as e:
|
||||
if 'lock timeout' in str(e):
|
||||
logger.error("Migration failed due to lock timeout")
|
||||
raise
|
||||
```
|
||||
|
||||
### Recovery Procedures
|
||||
|
||||
If migration fails in production:
|
||||
|
||||
1. **Check current state**
|
||||
|
||||
```bash
|
||||
alembic current
|
||||
alembic history
|
||||
```
|
||||
|
||||
2. **Manual rollback if needed**
|
||||
|
||||
```sql
|
||||
-- Check migration table
|
||||
SELECT * FROM alembic_version;
|
||||
|
||||
-- Force version if necessary
|
||||
UPDATE alembic_version SET version_num = 'previous_version';
|
||||
```
|
||||
|
||||
3. **Fix and retry**
|
||||
|
||||
```bash
|
||||
# Fix migration file
|
||||
vim migrations/versions/problematic_migration.py
|
||||
|
||||
# Retry
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Migration Guidelines
|
||||
|
||||
1. **Keep migrations small and focused**
|
||||
- One logical change per migration
|
||||
- Easier to review and rollback
|
||||
|
||||
2. **Test migrations thoroughly**
|
||||
- Test upgrade and downgrade
|
||||
- Test with production-like data
|
||||
- Test database-specific features
|
||||
|
||||
3. **Document breaking changes**
|
||||
|
||||
```python
|
||||
"""BREAKING: Change balance column type
|
||||
|
||||
This migration requires application update.
|
||||
Deploy order:
|
||||
1. Update application to handle both int and bigint
|
||||
2. Run this migration
|
||||
3. Update application to use only bigint
|
||||
"""
|
||||
```
|
||||
|
||||
4. **Make migrations idempotent**
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# Check if column exists
|
||||
inspector = inspect(op.get_bind())
|
||||
columns = [col['name'] for col in inspector.get_columns('apikey')]
|
||||
|
||||
if 'new_column' not in columns:
|
||||
op.add_column(
|
||||
'apikey',
|
||||
sa.Column('new_column', sa.String())
|
||||
)
|
||||
```
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
1. **Add indexes concurrently (PostgreSQL)**
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# Create index without locking table
|
||||
op.create_index(
|
||||
'ix_large_table_column',
|
||||
'large_table',
|
||||
['column'],
|
||||
postgresql_concurrently=True
|
||||
)
|
||||
```
|
||||
|
||||
2. **Batch large updates**
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
connection = op.get_bind()
|
||||
|
||||
# Process in batches
|
||||
batch_size = 1000
|
||||
offset = 0
|
||||
|
||||
while True:
|
||||
result = connection.execute(
|
||||
f"UPDATE apikey SET processed = true "
|
||||
f"WHERE id IN (SELECT id FROM apikey WHERE processed = false LIMIT {batch_size})"
|
||||
)
|
||||
|
||||
if result.rowcount == 0:
|
||||
break
|
||||
|
||||
offset += batch_size
|
||||
time.sleep(0.1) # Prevent overload
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Testing Guide](../contributing/testing.md) - Testing migrations
|
||||
- [Deployment](../getting-started/docker.md) - Production deployment
|
||||
@@ -0,0 +1,592 @@
|
||||
# Nostr Discovery
|
||||
|
||||
Routstr Core integrates with Nostr (Notes and Other Stuff Transmitted by Relays) for decentralized provider discovery. This enables users to find Routstr nodes without relying on centralized directories.
|
||||
|
||||
## Overview
|
||||
|
||||
Nostr integration provides:
|
||||
- **Decentralized Discovery**: Find providers through relay network
|
||||
- **Cryptographic Identity**: Providers identified by public keys
|
||||
- **Real-time Updates**: Live provider status and pricing
|
||||
- **Censorship Resistance**: No central point of control
|
||||
|
||||
## How It Works
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Routstr Node] --> B[Nostr Relay]
|
||||
B --> C[Nostr Relay]
|
||||
B --> D[Nostr Relay]
|
||||
|
||||
E[User Client] --> B
|
||||
E --> C
|
||||
E --> D
|
||||
|
||||
B --> F[Provider List]
|
||||
C --> F
|
||||
D --> F
|
||||
```
|
||||
|
||||
Providers announce themselves by publishing signed events to Nostr relays. Clients can query these relays to discover available providers.
|
||||
|
||||
## Provider Configuration
|
||||
|
||||
### Setting Up Nostr Identity
|
||||
|
||||
1. **Generate Nostr Keys**
|
||||
```bash
|
||||
# Using nostril or similar tool
|
||||
nostril --generate-keypair
|
||||
|
||||
# Output:
|
||||
# Private key (nsec): nsec1abc...
|
||||
# Public key (npub): npub1xyz...
|
||||
```
|
||||
|
||||
2. **Configure Environment**
|
||||
```bash
|
||||
# .env
|
||||
NPUB=npub1xyz... # Your public key
|
||||
NSEC=nsec1abc... # Your private key (keep secret!)
|
||||
NAME=Lightning AI Gateway
|
||||
DESCRIPTION=Fast and reliable AI API with Bitcoin payments
|
||||
HTTP_URL=https://api.lightning-ai.com
|
||||
ONION_URL=http://lightningai.onion
|
||||
```
|
||||
|
||||
### Publishing to Nostr
|
||||
|
||||
Routstr automatically publishes provider information to configured relays:
|
||||
|
||||
```python
|
||||
# Published event structure (NIP-89)
|
||||
{
|
||||
"kind": 31990, # Application handler event
|
||||
"pubkey": "your_public_key",
|
||||
"content": {
|
||||
"name": "Lightning AI Gateway",
|
||||
"description": "Fast and reliable AI API",
|
||||
"endpoints": {
|
||||
"http": "https://api.lightning-ai.com",
|
||||
"onion": "http://lightningai.onion"
|
||||
},
|
||||
"models": ["gpt-3.5-turbo", "gpt-4", "claude-3"],
|
||||
"pricing": {
|
||||
"gpt-3.5-turbo": {
|
||||
"prompt_sats_per_1k": 3,
|
||||
"completion_sats_per_1k": 4
|
||||
}
|
||||
},
|
||||
"cashu_mints": [
|
||||
"https://mint.minibits.cash/Bitcoin"
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
["d", "routstr"],
|
||||
["t", "ai-api"],
|
||||
["t", "bitcoin"],
|
||||
["p", "payment-proxy"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Relay Configuration
|
||||
|
||||
Configure which relays to publish to:
|
||||
|
||||
```python
|
||||
# Default relays
|
||||
DEFAULT_RELAYS = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.nostr.band",
|
||||
"wss://nostr.mom",
|
||||
"wss://nos.lol"
|
||||
]
|
||||
|
||||
# Custom relay configuration
|
||||
NOSTR_RELAYS=wss://relay1.com,wss://relay2.com
|
||||
```
|
||||
|
||||
## Client Discovery
|
||||
|
||||
### Using the Discovery Endpoint
|
||||
|
||||
Find providers through the API:
|
||||
|
||||
```bash
|
||||
GET /v1/providers
|
||||
|
||||
Response:
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"name": "Lightning AI Gateway",
|
||||
"npub": "npub1xyz...",
|
||||
"description": "Fast and reliable AI API",
|
||||
"endpoints": {
|
||||
"http": "https://api.lightning-ai.com",
|
||||
"onion": "http://lightningai.onion"
|
||||
},
|
||||
"models": ["gpt-3.5-turbo", "gpt-4"],
|
||||
"pricing": {
|
||||
"gpt-3.5-turbo": {
|
||||
"prompt_sats_per_1k": 3,
|
||||
"completion_sats_per_1k": 4
|
||||
}
|
||||
},
|
||||
"last_seen": "2024-01-01T12:00:00Z",
|
||||
"reliability_score": 0.99
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Direct Nostr Queries
|
||||
|
||||
Query Nostr relays directly:
|
||||
|
||||
```python
|
||||
import json
|
||||
import websocket
|
||||
|
||||
def discover_providers(relay_url: str):
|
||||
"""Discover Routstr providers from Nostr relay."""
|
||||
ws = websocket.create_connection(relay_url)
|
||||
|
||||
# Subscribe to provider events
|
||||
subscription = {
|
||||
"kinds": [31990],
|
||||
"tags": {
|
||||
"d": ["routstr"]
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(json.dumps(["REQ", "sub1", subscription]))
|
||||
|
||||
providers = []
|
||||
while True:
|
||||
response = json.loads(ws.recv())
|
||||
if response[0] == "EVENT":
|
||||
event = response[2]
|
||||
providers.append(parse_provider_event(event))
|
||||
elif response[0] == "EOSE": # End of stored events
|
||||
break
|
||||
|
||||
ws.close()
|
||||
return providers
|
||||
```
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
```typescript
|
||||
import { SimplePool } from 'nostr-tools';
|
||||
|
||||
async function discoverProviders(): Promise<Provider[]> {
|
||||
const pool = new SimplePool();
|
||||
const relays = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.nostr.band'
|
||||
];
|
||||
|
||||
const filter = {
|
||||
kinds: [31990],
|
||||
'#d': ['routstr']
|
||||
};
|
||||
|
||||
const events = await pool.list(relays, [filter]);
|
||||
|
||||
return events.map(event => ({
|
||||
name: event.content.name,
|
||||
npub: nip19.npubEncode(event.pubkey),
|
||||
url: event.content.endpoints.http,
|
||||
models: event.content.models,
|
||||
pricing: event.content.pricing
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
## Provider Ranking
|
||||
|
||||
### Reliability Scoring
|
||||
|
||||
Providers are ranked based on:
|
||||
|
||||
```python
|
||||
class ProviderScore:
|
||||
def calculate(self, provider: Provider) -> float:
|
||||
score = 1.0
|
||||
|
||||
# Uptime (based on recent checks)
|
||||
uptime_ratio = provider.successful_pings / provider.total_pings
|
||||
score *= uptime_ratio
|
||||
|
||||
# Response time
|
||||
if provider.avg_response_time < 500: # ms
|
||||
score *= 1.0
|
||||
elif provider.avg_response_time < 1000:
|
||||
score *= 0.9
|
||||
else:
|
||||
score *= 0.7
|
||||
|
||||
# Model availability
|
||||
model_score = len(provider.models) / 10 # Max 10 models
|
||||
score *= min(1.0, 0.5 + model_score * 0.5)
|
||||
|
||||
# Price competitiveness
|
||||
if provider.is_cheapest_for_any_model():
|
||||
score *= 1.1
|
||||
|
||||
return min(1.0, score)
|
||||
```
|
||||
|
||||
### Provider Selection
|
||||
|
||||
Choose optimal provider:
|
||||
|
||||
```python
|
||||
def select_provider(
|
||||
providers: list[Provider],
|
||||
model: str,
|
||||
requirements: dict
|
||||
) -> Provider:
|
||||
"""Select best provider for requirements."""
|
||||
|
||||
# Filter by model availability
|
||||
candidates = [p for p in providers if model in p.models]
|
||||
|
||||
# Filter by requirements
|
||||
if requirements.get('tor_required'):
|
||||
candidates = [p for p in candidates if p.onion_url]
|
||||
|
||||
if requirements.get('max_price_per_1k'):
|
||||
max_price = requirements['max_price_per_1k']
|
||||
candidates = [
|
||||
p for p in candidates
|
||||
if p.pricing[model]['prompt_sats_per_1k'] <= max_price
|
||||
]
|
||||
|
||||
# Sort by score
|
||||
candidates.sort(key=lambda p: p.reliability_score, reverse=True)
|
||||
|
||||
return candidates[0] if candidates else None
|
||||
```
|
||||
|
||||
## Publishing Updates
|
||||
|
||||
### Automatic Updates
|
||||
|
||||
Routstr publishes updates when:
|
||||
- Node starts up
|
||||
- Configuration changes
|
||||
- Models are added/removed
|
||||
- Pricing updates
|
||||
|
||||
### Manual Publishing
|
||||
|
||||
Force publish current state:
|
||||
|
||||
```python
|
||||
async def publish_provider_info():
|
||||
"""Manually publish provider information."""
|
||||
event = create_provider_event(
|
||||
name=os.getenv("NAME"),
|
||||
description=os.getenv("DESCRIPTION"),
|
||||
models=get_available_models(),
|
||||
pricing=get_current_pricing()
|
||||
)
|
||||
|
||||
await publish_to_relays(event, NOSTR_RELAYS)
|
||||
```
|
||||
|
||||
### Event Lifecycle
|
||||
|
||||
```python
|
||||
# Publish every 6 hours
|
||||
@periodic_task(hours=6)
|
||||
async def update_nostr_presence():
|
||||
"""Keep provider information fresh."""
|
||||
try:
|
||||
await publish_provider_info()
|
||||
logger.info("Updated Nostr presence")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update Nostr: {e}")
|
||||
|
||||
# Delete on shutdown
|
||||
async def remove_nostr_presence():
|
||||
"""Remove provider from discovery."""
|
||||
deletion_event = create_deletion_event()
|
||||
await publish_to_relays(deletion_event, NOSTR_RELAYS)
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Key Management
|
||||
|
||||
1. **Secure Storage**
|
||||
```python
|
||||
# Never log private keys
|
||||
SENSITIVE_VARS = ['NSEC', 'ADMIN_PASSWORD']
|
||||
|
||||
def sanitize_env(env_dict: dict) -> dict:
|
||||
return {
|
||||
k: '***' if k in SENSITIVE_VARS else v
|
||||
for k, v in env_dict.items()
|
||||
}
|
||||
```
|
||||
|
||||
2. **Key Rotation**
|
||||
```bash
|
||||
# Generate new keys
|
||||
nostril --generate-keypair
|
||||
|
||||
# Update configuration
|
||||
# Publish transition event
|
||||
# Update all references
|
||||
```
|
||||
|
||||
### Event Validation
|
||||
|
||||
Verify provider events:
|
||||
|
||||
```python
|
||||
def validate_provider_event(event: dict) -> bool:
|
||||
"""Validate provider announcement."""
|
||||
# Check signature
|
||||
if not verify_signature(event):
|
||||
return False
|
||||
|
||||
# Check required fields
|
||||
required = ['name', 'endpoints', 'models', 'pricing']
|
||||
content = json.loads(event['content'])
|
||||
if not all(field in content for field in required):
|
||||
return False
|
||||
|
||||
# Verify endpoints are reachable
|
||||
if not await check_endpoints(content['endpoints']):
|
||||
return False
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
### Relay Security
|
||||
|
||||
Choose relays carefully:
|
||||
|
||||
```python
|
||||
TRUSTED_RELAYS = {
|
||||
'wss://relay.damus.io': {
|
||||
'operator': 'Damus',
|
||||
'reputation': 'high',
|
||||
'filters_spam': True
|
||||
},
|
||||
'wss://relay.nostr.band': {
|
||||
'operator': 'Nostr.Band',
|
||||
'reputation': 'high',
|
||||
'paid_tier': True
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Multi-Relay Broadcasting
|
||||
|
||||
Ensure wide distribution:
|
||||
|
||||
```python
|
||||
async def broadcast_to_relays(event: dict, relays: list[str]):
|
||||
"""Broadcast event to multiple relays."""
|
||||
tasks = []
|
||||
for relay in relays:
|
||||
task = asyncio.create_task(
|
||||
publish_to_relay(event, relay)
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
successful = sum(1 for r in results if not isinstance(r, Exception))
|
||||
logger.info(f"Published to {successful}/{len(relays)} relays")
|
||||
```
|
||||
|
||||
### Provider Metadata
|
||||
|
||||
Extended metadata in events:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31990,
|
||||
"content": {
|
||||
"name": "Lightning AI",
|
||||
"description": "Enterprise AI API",
|
||||
"metadata": {
|
||||
"established": "2024-01-01",
|
||||
"total_requests": 1000000,
|
||||
"average_response_ms": 250,
|
||||
"supported_features": [
|
||||
"streaming",
|
||||
"function_calling",
|
||||
"vision",
|
||||
"embeddings"
|
||||
],
|
||||
"certifications": ["SOC2", "GDPR"],
|
||||
"contact": {
|
||||
"nostr": "npub1contact...",
|
||||
"email": "support@lightning-ai.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Discovery Filters
|
||||
|
||||
Advanced filtering options:
|
||||
|
||||
```python
|
||||
# Find providers with specific features
|
||||
GET /v1/providers?features=streaming,vision&max_price=5&min_reliability=0.95
|
||||
|
||||
# Response includes filtered results
|
||||
{
|
||||
"providers": [...],
|
||||
"filters_applied": {
|
||||
"features": ["streaming", "vision"],
|
||||
"max_price_sats_per_1k": 5,
|
||||
"min_reliability": 0.95
|
||||
},
|
||||
"total_providers": 50,
|
||||
"matching_providers": 12
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Discovery Metrics
|
||||
|
||||
Track discovery performance:
|
||||
|
||||
```python
|
||||
class DiscoveryMetrics:
|
||||
def __init__(self):
|
||||
self.relay_health = {}
|
||||
self.provider_count = 0
|
||||
self.query_latency = []
|
||||
|
||||
async def check_relay_health(self, relay_url: str):
|
||||
"""Monitor relay connectivity."""
|
||||
start = time.time()
|
||||
try:
|
||||
await connect_to_relay(relay_url)
|
||||
latency = time.time() - start
|
||||
self.relay_health[relay_url] = {
|
||||
'status': 'healthy',
|
||||
'latency_ms': latency * 1000
|
||||
}
|
||||
except Exception as e:
|
||||
self.relay_health[relay_url] = {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e)
|
||||
}
|
||||
```
|
||||
|
||||
### Provider Monitoring
|
||||
|
||||
```python
|
||||
@periodic_task(minutes=5)
|
||||
async def monitor_providers():
|
||||
"""Check provider health."""
|
||||
providers = await discover_providers()
|
||||
|
||||
for provider in providers:
|
||||
try:
|
||||
# Test endpoint
|
||||
response = await test_provider_endpoint(provider.http_url)
|
||||
|
||||
# Update metrics
|
||||
await update_provider_metrics(
|
||||
provider.npub,
|
||||
success=response.status_code == 200,
|
||||
response_time=response.elapsed
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Provider {provider.name} check failed: {e}")
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No Providers Found
|
||||
|
||||
```python
|
||||
# Debug discovery issues
|
||||
async def debug_discovery():
|
||||
"""Diagnose discovery problems."""
|
||||
issues = []
|
||||
|
||||
# Check relay connectivity
|
||||
for relay in NOSTR_RELAYS:
|
||||
if not await can_connect_to_relay(relay):
|
||||
issues.append(f"Cannot connect to {relay}")
|
||||
|
||||
# Check event publishing
|
||||
if not await verify_own_events_visible():
|
||||
issues.append("Own events not visible on relays")
|
||||
|
||||
# Check filters
|
||||
if len(await get_all_provider_events()) == 0:
|
||||
issues.append("No provider events on any relay")
|
||||
|
||||
return issues
|
||||
```
|
||||
|
||||
### Relay Connection Issues
|
||||
|
||||
```bash
|
||||
# Test relay connection
|
||||
wscat -c wss://relay.damus.io
|
||||
|
||||
# Send subscription
|
||||
["REQ","test",{"kinds":[31990],"#d":["routstr"]}]
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Providers
|
||||
|
||||
1. **Consistent Identity**
|
||||
- Use same npub across services
|
||||
- Maintain profile metadata
|
||||
- Verify identity on multiple platforms
|
||||
|
||||
2. **Regular Updates**
|
||||
- Publish status every few hours
|
||||
- Update pricing promptly
|
||||
- Remove stale information
|
||||
|
||||
3. **Relay Diversity**
|
||||
- Publish to 5+ relays
|
||||
- Include regional relays
|
||||
- Monitor relay health
|
||||
|
||||
### For Clients
|
||||
|
||||
1. **Verify Providers**
|
||||
- Check multiple relays
|
||||
- Verify endpoints work
|
||||
- Monitor reliability over time
|
||||
|
||||
2. **Cache Discovery**
|
||||
- Cache provider list
|
||||
- Refresh periodically
|
||||
- Handle stale data gracefully
|
||||
|
||||
3. **Fallback Options**
|
||||
- Keep backup providers
|
||||
- Handle discovery failures
|
||||
- Support manual configuration
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Tor Support](tor.md) - Anonymous provider access
|
||||
- [Custom Pricing](custom-pricing.md) - Dynamic pricing strategies
|
||||
- [API Reference](../api/endpoints.md) - Discovery API details
|
||||
@@ -0,0 +1,530 @@
|
||||
# Tor Support
|
||||
|
||||
Routstr Core includes built-in support for Tor hidden services, enabling anonymous access to your API and enhanced privacy for users.
|
||||
|
||||
## Overview
|
||||
|
||||
Tor support provides:
|
||||
|
||||
- **Anonymous Access**: Hidden service (.onion) address
|
||||
- **Enhanced Privacy**: No IP address logging
|
||||
- **Censorship Resistance**: Accessible from restricted networks
|
||||
- **Optional Usage**: Regular HTTP/HTTPS access remains available
|
||||
|
||||
## Docker Setup
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
The included `compose.yml` automatically sets up Tor:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
routstr:
|
||||
build: .
|
||||
environment:
|
||||
- TOR_PROXY_URL=socks5://tor:9050
|
||||
ports:
|
||||
- 8000:8000
|
||||
|
||||
tor:
|
||||
image: ghcr.io/hundehausen/tor-hidden-service:latest
|
||||
volumes:
|
||||
- tor-data:/var/lib/tor
|
||||
environment:
|
||||
- HS_ROUTER=routstr:8000:80
|
||||
depends_on:
|
||||
- routstr
|
||||
|
||||
volumes:
|
||||
tor-data:
|
||||
```
|
||||
|
||||
Start with:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Getting Your Onion Address
|
||||
|
||||
After starting, retrieve your hidden service address:
|
||||
|
||||
```bash
|
||||
# View Tor logs
|
||||
docker compose logs tor
|
||||
|
||||
# Or directly from the hostname file
|
||||
docker exec tor cat /var/lib/tor/hidden_service/hostname
|
||||
```
|
||||
|
||||
Your onion address will look like:
|
||||
|
||||
```
|
||||
roustrjfsdgfiueghsklchg.onion
|
||||
```
|
||||
|
||||
## Manual Tor Setup
|
||||
|
||||
### Install Tor
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install tor
|
||||
|
||||
# macOS
|
||||
brew install tor
|
||||
|
||||
# Start Tor
|
||||
sudo systemctl start tor
|
||||
```
|
||||
|
||||
### Configure Hidden Service
|
||||
|
||||
Edit `/etc/tor/torrc`:
|
||||
|
||||
```bash
|
||||
# Hidden service configuration
|
||||
HiddenServiceDir /var/lib/tor/routstr/
|
||||
HiddenServicePort 80 127.0.0.1:8000
|
||||
|
||||
# Optional: Restrict to v3 addresses
|
||||
HiddenServiceVersion 3
|
||||
```
|
||||
|
||||
Restart Tor:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart tor
|
||||
```
|
||||
|
||||
Get onion address:
|
||||
|
||||
```bash
|
||||
sudo cat /var/lib/tor/routstr/hostname
|
||||
```
|
||||
|
||||
## Client Configuration
|
||||
|
||||
### Using Tor with Python
|
||||
|
||||
```python
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
# Configure SOCKS proxy
|
||||
proxies = {
|
||||
"http://": "socks5://127.0.0.1:9050",
|
||||
"https://": "socks5://127.0.0.1:9050"
|
||||
}
|
||||
|
||||
# Create client with Tor
|
||||
http_client = httpx.Client(proxies=proxies)
|
||||
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="http://roustrjfsdgfiueghsklchg.onion/v1",
|
||||
http_client=http_client
|
||||
)
|
||||
|
||||
# Use normally
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello via Tor!"}]
|
||||
)
|
||||
```
|
||||
|
||||
### Using Tor with cURL
|
||||
|
||||
```bash
|
||||
# Install torify
|
||||
sudo apt-get install torsocks
|
||||
|
||||
# Make request through Tor
|
||||
torify curl http://roustrjfsdgfiueghsklchg.onion/v1/models
|
||||
|
||||
# Or with explicit proxy
|
||||
curl --socks5 127.0.0.1:9050 http://roustrjfsdgfiueghsklchg.onion/v1/models
|
||||
```
|
||||
|
||||
### JavaScript/Node.js
|
||||
|
||||
```javascript
|
||||
import { SocksProxyAgent } from 'socks-proxy-agent';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
// Create SOCKS agent
|
||||
const agent = new SocksProxyAgent('socks5://127.0.0.1:9050');
|
||||
|
||||
// Configure OpenAI client
|
||||
const openai = new OpenAI({
|
||||
apiKey: 'sk-...',
|
||||
baseURL: 'http://roustrjfsdgfiueghsklchg.onion/v1',
|
||||
httpAgent: agent
|
||||
});
|
||||
|
||||
// Use normally
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-3.5-turbo',
|
||||
messages: [{ role: 'user', content: 'Hello via Tor!' }]
|
||||
});
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Configure Tor proxy for outgoing connections:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
TOR_PROXY_URL=socks5://tor:9050 # Docker
|
||||
# or
|
||||
TOR_PROXY_URL=socks5://127.0.0.1:9050 # Local
|
||||
```
|
||||
|
||||
### Publishing Onion Address
|
||||
|
||||
Make your onion address discoverable:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
ONION_URL=http://roustrjfsdgfiueghsklchg.onion
|
||||
```
|
||||
|
||||
This will be included in:
|
||||
|
||||
- `/v1/info` endpoint
|
||||
- Nostr announcements
|
||||
- Admin dashboard
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Hidden Service Security
|
||||
|
||||
1. **Keep Private Key Secure**
|
||||
|
||||
```bash
|
||||
# Backup hidden service keys
|
||||
sudo tar -czf tor-keys-backup.tar.gz /var/lib/tor/routstr/
|
||||
|
||||
# Restore to maintain same address
|
||||
sudo tar -xzf tor-keys-backup.tar.gz -C /
|
||||
```
|
||||
|
||||
2. **Access Control**
|
||||
|
||||
```bash
|
||||
# Restrict to authenticated clients
|
||||
HiddenServiceAuthorizeClient stealth client1,client2
|
||||
```
|
||||
|
||||
3. **Rate Limiting**
|
||||
|
||||
```bash
|
||||
# In torrc
|
||||
HiddenServiceMaxStreams 100
|
||||
HiddenServiceMaxStreamsCloseCircuit 1
|
||||
```
|
||||
|
||||
### Operational Security
|
||||
|
||||
1. **Separate Tor Instance**
|
||||
|
||||
```yaml
|
||||
# Use dedicated Tor container
|
||||
tor:
|
||||
image: ghcr.io/hundehausen/tor-hidden-service:latest
|
||||
restart: always
|
||||
networks:
|
||||
- tor_network
|
||||
```
|
||||
|
||||
2. **Monitor Tor Health**
|
||||
|
||||
```python
|
||||
async def check_tor_connection():
|
||||
"""Verify Tor connectivity."""
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
proxies={"all://": TOR_PROXY_URL}
|
||||
) as client:
|
||||
response = await client.get(
|
||||
"https://check.torproject.org/api/ip"
|
||||
)
|
||||
data = response.json()
|
||||
return data.get("IsTor", False)
|
||||
except Exception:
|
||||
return False
|
||||
```
|
||||
|
||||
3. **Logging Considerations**
|
||||
|
||||
```python
|
||||
# Don't log .onion addresses with IPs
|
||||
def sanitize_logs(message: str) -> str:
|
||||
# Remove IP addresses when .onion is present
|
||||
if ".onion" in message:
|
||||
message = re.sub(r'\d+\.\d+\.\d+\.\d+', '[IP]', message)
|
||||
return message
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Connection Pooling
|
||||
|
||||
```python
|
||||
# Reuse Tor circuits
|
||||
class TorConnectionPool:
|
||||
def __init__(self, proxy_url: str):
|
||||
self.proxy_url = proxy_url
|
||||
self._clients = []
|
||||
|
||||
async def get_client(self) -> httpx.AsyncClient:
|
||||
if not self._clients:
|
||||
client = httpx.AsyncClient(
|
||||
proxies={"all://": self.proxy_url},
|
||||
timeout=httpx.Timeout(30.0),
|
||||
limits=httpx.Limits(
|
||||
max_keepalive_connections=5,
|
||||
max_connections=10
|
||||
)
|
||||
)
|
||||
self._clients.append(client)
|
||||
return self._clients[0]
|
||||
```
|
||||
|
||||
### Circuit Management
|
||||
|
||||
```python
|
||||
# Rotate Tor circuits periodically
|
||||
async def rotate_tor_circuit():
|
||||
"""Signal Tor to create new circuit."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Tor control port (requires configuration)
|
||||
response = await client.post(
|
||||
"http://localhost:9051",
|
||||
data="AUTHENTICATE\r\nSIGNAL NEWNYM\r\n"
|
||||
)
|
||||
```
|
||||
|
||||
### Caching Strategies
|
||||
|
||||
```python
|
||||
# Cache responses for Tor users
|
||||
@lru_cache(maxsize=1000)
|
||||
def get_cached_response(
|
||||
endpoint: str,
|
||||
params_hash: str
|
||||
) -> Optional[dict]:
|
||||
"""Cache frequently accessed data."""
|
||||
# Longer cache for Tor users due to latency
|
||||
return cache.get(f"tor:{endpoint}:{params_hash}")
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Tor Metrics
|
||||
|
||||
Track Tor-specific metrics:
|
||||
|
||||
```python
|
||||
class TorMetrics:
|
||||
def __init__(self):
|
||||
self.tor_requests = 0
|
||||
self.tor_errors = 0
|
||||
self.circuit_builds = 0
|
||||
self.average_latency = 0
|
||||
|
||||
async def record_request(
|
||||
self,
|
||||
duration: float,
|
||||
success: bool
|
||||
):
|
||||
self.tor_requests += 1
|
||||
if not success:
|
||||
self.tor_errors += 1
|
||||
|
||||
# Update average latency
|
||||
self.average_latency = (
|
||||
(self.average_latency * (self.tor_requests - 1) + duration)
|
||||
/ self.tor_requests
|
||||
)
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
|
||||
```python
|
||||
@router.get("/health/tor")
|
||||
async def tor_health():
|
||||
"""Check Tor service health."""
|
||||
checks = {
|
||||
"tor_proxy": await check_tor_proxy(),
|
||||
"hidden_service": await check_hidden_service(),
|
||||
"circuit_established": await check_circuit()
|
||||
}
|
||||
|
||||
status = "healthy" if all(checks.values()) else "unhealthy"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"checks": checks,
|
||||
"metrics": {
|
||||
"tor_requests_total": metrics.tor_requests,
|
||||
"tor_error_rate": metrics.tor_errors / max(metrics.tor_requests, 1),
|
||||
"average_latency_ms": metrics.average_latency * 1000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Hidden Service Not Accessible**
|
||||
|
||||
```bash
|
||||
# Check Tor logs
|
||||
docker compose logs tor
|
||||
# or
|
||||
sudo journalctl -u tor
|
||||
|
||||
# Verify service is running
|
||||
sudo systemctl status tor
|
||||
|
||||
# Test locally
|
||||
curl --socks5 127.0.0.1:9050 http://your-onion.onion/v1/info
|
||||
```
|
||||
|
||||
**Slow Connection**
|
||||
|
||||
- Tor adds 3+ hops of latency
|
||||
- Use connection pooling
|
||||
- Implement aggressive caching
|
||||
- Consider increasing timeouts
|
||||
|
||||
**Connection Errors**
|
||||
|
||||
```python
|
||||
# Implement Tor-specific retry logic
|
||||
async def tor_retry(func, max_retries=5):
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return await func()
|
||||
except httpx.ProxyError:
|
||||
if attempt < max_retries - 1:
|
||||
# Exponential backoff for circuit building
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
else:
|
||||
raise
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
Enable Tor debug logging:
|
||||
|
||||
```bash
|
||||
# In torrc
|
||||
Log debug file /var/log/tor/debug.log
|
||||
|
||||
# Monitor in real-time
|
||||
tail -f /var/log/tor/debug.log
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Operators
|
||||
|
||||
1. **Backup Hidden Service Keys**
|
||||
- Store securely offline
|
||||
- Enables service recovery
|
||||
- Maintains same .onion address
|
||||
|
||||
2. **Monitor Tor Health**
|
||||
- Check circuit establishment
|
||||
- Track request latency
|
||||
- Alert on failures
|
||||
|
||||
3. **Separate Concerns**
|
||||
- Run Tor in separate container
|
||||
- Isolate from main application
|
||||
- Use internal networks
|
||||
|
||||
### For Users
|
||||
|
||||
1. **Verify Onion Addresses**
|
||||
- Check against multiple sources
|
||||
- Bookmark verified addresses
|
||||
- Watch for phishing
|
||||
|
||||
2. **Handle Higher Latency**
|
||||
- Increase client timeouts
|
||||
- Implement retries
|
||||
- Use connection pooling
|
||||
|
||||
3. **Enhance Privacy**
|
||||
- Use Tor Browser for web access
|
||||
- Avoid mixing Tor/clearnet
|
||||
- Don't include identifying info
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Multi-Hop Onion Services
|
||||
|
||||
For extra security, chain multiple Tor instances:
|
||||
|
||||
```yaml
|
||||
# compose.yml
|
||||
services:
|
||||
tor-entry:
|
||||
image: tor:latest
|
||||
command: tor -f /etc/tor/torrc.entry
|
||||
|
||||
tor-middle:
|
||||
image: tor:latest
|
||||
command: tor -f /etc/tor/torrc.middle
|
||||
|
||||
tor-exit:
|
||||
image: tor:latest
|
||||
command: tor -f /etc/tor/torrc.exit
|
||||
```
|
||||
|
||||
### Onion Service Authentication
|
||||
|
||||
Require client authorization:
|
||||
|
||||
```bash
|
||||
# Generate client auth
|
||||
openssl rand -base64 32 > client_auth_key
|
||||
|
||||
# In torrc
|
||||
HiddenServiceDir /var/lib/tor/routstr/
|
||||
HiddenServicePort 80 127.0.0.1:8000
|
||||
HiddenServiceAuthorizeClient stealth payments
|
||||
```
|
||||
|
||||
### Load Balancing
|
||||
|
||||
Distribute load across multiple instances:
|
||||
|
||||
```nginx
|
||||
# Onion service nginx config
|
||||
upstream routstr_backends {
|
||||
server routstr1:8000;
|
||||
server routstr2:8000;
|
||||
server routstr3:8000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
location / {
|
||||
proxy_pass http://routstr_backends;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Nostr Discovery](nostr.md) - Announce your onion service
|
||||
- [Docker Setup](../getting-started/docker.md) - Container configuration
|
||||
@@ -0,0 +1,428 @@
|
||||
# Authentication
|
||||
|
||||
Routstr uses API key authentication for all protected endpoints. This guide covers how to create, use, and manage API keys.
|
||||
|
||||
## API Key Creation
|
||||
|
||||
### From eCash Token
|
||||
|
||||
Create an API key by depositing an eCash token:
|
||||
|
||||
**Note: The POST /v1/wallet/create endpoint is coming soon. Currently, you can use Cashu tokens directly as API credentials in the Authorization header. The token is hashed on the server, and the hash acts as an API key with the token's balance.**
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/create
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"cashu_token": "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vbWlu..."
|
||||
}
|
||||
```
|
||||
|
||||
**Request Parameters:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `cashu_token` | string | Yes | Base64-encoded Cashu token |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p",
|
||||
"balance": 10000,
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"key_id": "key_123456"
|
||||
}
|
||||
```
|
||||
|
||||
### From Lightning Invoice (Coming Soon)
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/create/lightning
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"amount_sats": 10000,
|
||||
"name": "Lightning Key"
|
||||
}
|
||||
```
|
||||
|
||||
Response includes Lightning invoice for payment.
|
||||
|
||||
## Using API Keys
|
||||
|
||||
### Header Authentication
|
||||
|
||||
Include the API key in the Authorization header:
|
||||
|
||||
```bash
|
||||
curl https://your-node.com/v1/chat/completions \
|
||||
-H "Authorization: Bearer sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"Hello"}]}'
|
||||
```
|
||||
|
||||
### Query Parameter (Not Recommended)
|
||||
|
||||
For tools that don't support headers:
|
||||
|
||||
```bash
|
||||
GET /v1/models?api_key=sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p
|
||||
```
|
||||
|
||||
⚠️ **Warning**: Query parameters may be logged. Use headers when possible.
|
||||
|
||||
## Key Management
|
||||
|
||||
### Check Balance
|
||||
|
||||
Get current balance and usage statistics:
|
||||
|
||||
```bash
|
||||
GET /v1/wallet/balance
|
||||
Authorization: Bearer sk-...
|
||||
|
||||
Response:
|
||||
{
|
||||
"balance": 8546,
|
||||
"total_deposited": 10000,
|
||||
"total_spent": 1454,
|
||||
"last_used": "2024-01-01T12:34:56Z",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"expires_at": null,
|
||||
"key_info": {
|
||||
"name": "Production Key",
|
||||
"key_id": "key_123456"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Top Up Balance
|
||||
|
||||
Add funds to existing key:
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/topup
|
||||
Authorization: Bearer sk-...
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"cashu_token": "cashuAeyJ0b2tlbiI6W3..."
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"old_balance": 8546,
|
||||
"added_amount": 5000,
|
||||
"new_balance": 13546,
|
||||
"transaction_id": "txn_789"
|
||||
}
|
||||
```
|
||||
|
||||
### List Transactions
|
||||
|
||||
View transaction history:
|
||||
|
||||
```bash
|
||||
GET /v1/wallet/transactions?limit=10
|
||||
Authorization: Bearer sk-...
|
||||
|
||||
Response:
|
||||
{
|
||||
"transactions": [
|
||||
{
|
||||
"id": "txn_123",
|
||||
"type": "usage",
|
||||
"amount": -154,
|
||||
"balance_after": 8546,
|
||||
"description": "gpt-3.5-turbo: 50 prompt + 150 completion tokens",
|
||||
"timestamp": "2024-01-01T12:34:56Z"
|
||||
},
|
||||
{
|
||||
"id": "txn_122",
|
||||
"type": "deposit",
|
||||
"amount": 10000,
|
||||
"balance_after": 10000,
|
||||
"description": "Initial deposit",
|
||||
"timestamp": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"total": 2
|
||||
}
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### API Key Storage
|
||||
|
||||
**Do:**
|
||||
|
||||
- Store keys in environment variables
|
||||
- Use secret management systems
|
||||
- Encrypt keys at rest
|
||||
- Implement key rotation
|
||||
|
||||
**Don't:**
|
||||
|
||||
- Commit keys to version control
|
||||
- Share keys between environments
|
||||
- Log keys in plain text
|
||||
- Expose keys in client-side code
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# .env file
|
||||
ROUTSTR_API_KEY=sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p
|
||||
ROUTSTR_BASE_URL=https://your-node.com/v1
|
||||
|
||||
# Usage in code
|
||||
import os
|
||||
api_key = os.getenv("ROUTSTR_API_KEY")
|
||||
```
|
||||
|
||||
### Key Rotation
|
||||
|
||||
Regularly rotate API keys:
|
||||
|
||||
```python
|
||||
# 1. Create new key
|
||||
new_key = create_api_key(balance=old_key_balance)
|
||||
|
||||
# 2. Update applications
|
||||
update_environment_variable("ROUTSTR_API_KEY", new_key)
|
||||
|
||||
# 3. Test new key
|
||||
test_api_connection(new_key)
|
||||
|
||||
# 4. Withdraw old key balance
|
||||
withdraw_balance(old_key)
|
||||
```
|
||||
|
||||
## Authentication Errors
|
||||
|
||||
### Invalid API Key
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "authentication_failed",
|
||||
"message": "Invalid API key",
|
||||
"code": "invalid_api_key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status Code:** 401
|
||||
|
||||
**Common Causes:**
|
||||
|
||||
- Typo in API key
|
||||
- Key doesn't exist
|
||||
- Key has been deleted
|
||||
|
||||
### Expired API Key
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "authentication_failed",
|
||||
"message": "API key has expired",
|
||||
"code": "key_expired",
|
||||
"details": {
|
||||
"expired_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status Code:** 401
|
||||
|
||||
**Resolution:**
|
||||
|
||||
- Create a new API key
|
||||
- Contact admin if refund address was set
|
||||
|
||||
### Insufficient Balance
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "insufficient_balance",
|
||||
"message": "Insufficient balance for request",
|
||||
"code": "payment_required",
|
||||
"details": {
|
||||
"balance": 100,
|
||||
"required": 154,
|
||||
"shortfall": 54
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status Code:** 402
|
||||
|
||||
**Resolution:**
|
||||
|
||||
- Top up the API key balance
|
||||
- Use a more economical model
|
||||
- Optimize request parameters
|
||||
|
||||
## Advanced Authentication
|
||||
|
||||
### Per-Request Tokens (Coming Soon)
|
||||
|
||||
Pay per request without maintaining a balance:
|
||||
|
||||
```bash
|
||||
curl https://your-node.com/v1/chat/completions \
|
||||
-H "X-Cashu: cashuAeyJ0b2tlbiI6W3..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"gpt-3.5-turbo","messages":[...]}'
|
||||
```
|
||||
|
||||
Response includes change:
|
||||
|
||||
```
|
||||
X-Cashu: cashuAeyJjaGFuZ2UiOlt7...
|
||||
```
|
||||
|
||||
### Multi-Key Authentication
|
||||
|
||||
Use multiple keys for different purposes:
|
||||
|
||||
```python
|
||||
# Production key for main app
|
||||
PROD_KEY = os.getenv("ROUTSTR_PROD_KEY")
|
||||
|
||||
# Development key for testing
|
||||
DEV_KEY = os.getenv("ROUTSTR_DEV_KEY")
|
||||
|
||||
# Analytics key with restricted permissions
|
||||
ANALYTICS_KEY = os.getenv("ROUTSTR_ANALYTICS_KEY")
|
||||
|
||||
# Choose key based on environment
|
||||
api_key = PROD_KEY if is_production() else DEV_KEY
|
||||
```
|
||||
|
||||
### Delegated Authentication
|
||||
|
||||
Create sub-keys with limited permissions:
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/create/subkey
|
||||
Authorization: Bearer sk-parent-key
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Limited Subkey",
|
||||
"balance_limit": 1000,
|
||||
"allowed_models": ["gpt-3.5-turbo"],
|
||||
"expires_in_hours": 24
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Rate limits are applied per API key:
|
||||
|
||||
### Default Limits
|
||||
|
||||
| Metric | Limit | Window |
|
||||
|--------|-------|--------|
|
||||
| Requests | 1000 | 1 minute |
|
||||
| Tokens | 1,000,000 | 1 hour |
|
||||
| Concurrent | 10 | - |
|
||||
|
||||
### Rate Limit Headers
|
||||
|
||||
```
|
||||
X-RateLimit-Limit: 1000
|
||||
X-RateLimit-Remaining: 999
|
||||
X-RateLimit-Reset: 1640995200
|
||||
X-RateLimit-Type: requests_per_minute
|
||||
```
|
||||
|
||||
### Handling Rate Limits
|
||||
|
||||
```python
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
def make_request_with_retry(
|
||||
client,
|
||||
max_retries: int = 3
|
||||
) -> Optional[Response]:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
response = client.chat.completions.create(...)
|
||||
return response
|
||||
except RateLimitError as e:
|
||||
if attempt < max_retries - 1:
|
||||
# Extract retry-after from error
|
||||
retry_after = e.retry_after or 60
|
||||
print(f"Rate limited. Waiting {retry_after}s...")
|
||||
time.sleep(retry_after)
|
||||
else:
|
||||
raise
|
||||
```
|
||||
|
||||
## IP Whitelisting
|
||||
|
||||
Restrict API key usage by IP:
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/update
|
||||
Authorization: Bearer sk-...
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"allowed_ips": [
|
||||
"192.168.1.100",
|
||||
"10.0.0.0/24"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Usage Alerts
|
||||
|
||||
Set up usage notifications:
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/alerts
|
||||
Authorization: Bearer sk-...
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"low_balance_threshold": 1000,
|
||||
"daily_spend_limit": 5000,
|
||||
"webhook_url": "https://your-app.com/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### Audit Logging
|
||||
|
||||
All API key usage is logged:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-01T12:34:56Z",
|
||||
"api_key_id": "key_123456",
|
||||
"endpoint": "/v1/chat/completions",
|
||||
"method": "POST",
|
||||
"ip_address": "192.168.1.100",
|
||||
"user_agent": "OpenAI-Python/1.0",
|
||||
"cost_sats": 154,
|
||||
"response_status": 200
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Endpoints](endpoints.md) - Complete endpoint reference
|
||||
- [Errors](errors.md) - Error handling guide
|
||||
- [Using the API](../user-guide/using-api.md) - Integration examples
|
||||
@@ -0,0 +1,509 @@
|
||||
# API Endpoints
|
||||
|
||||
Complete reference for all Routstr API endpoints.
|
||||
|
||||
## Overview
|
||||
|
||||
Routstr provides OpenAI-compatible endpoints with Bitcoin/eCash payment integration.
|
||||
|
||||
### Base URL
|
||||
|
||||
All endpoints use the base URL:
|
||||
|
||||
```text
|
||||
https://api.routstr.com/v1
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
All endpoints require authentication via:
|
||||
|
||||
- **Bearer Token**: `Authorization: Bearer sk-...` or `Authorization: Bearer cashuAeyJ0...`
|
||||
- **X-Cashu Header**: `X-Cashu: cashuAeyJ0...` (for direct eCash payments)
|
||||
|
||||
See [Authentication](authentication.md) for details.
|
||||
|
||||
## Chat
|
||||
|
||||
### Create Chat Completion
|
||||
|
||||
Send messages to generate model responses.
|
||||
|
||||
```http
|
||||
POST /v1/chat/completions
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gpt-4",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a helpful assistant."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hello!"
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"stream": false
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `model` | string | Yes | - | Model ID to use |
|
||||
| `messages` | array | Yes | - | Array of message objects |
|
||||
| `temperature` | number | No | 1.0 | Sampling temperature (0-2) |
|
||||
| `max_tokens` | integer | No | Model default | Maximum tokens to generate |
|
||||
| `stream` | boolean | No | false | Stream partial responses |
|
||||
| `top_p` | number | No | 1.0 | Nucleus sampling |
|
||||
| `n` | integer | No | 1 | Number of completions |
|
||||
| `stop` | string/array | No | null | Stop sequences |
|
||||
| `presence_penalty` | number | No | 0 | Presence penalty (-2 to 2) |
|
||||
| `frequency_penalty` | number | No | 0 | Frequency penalty (-2 to 2) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"created": 1677652288,
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Hello! How can I help you today?"
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 13,
|
||||
"completion_tokens": 9,
|
||||
"total_tokens": 22
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Streaming Response
|
||||
|
||||
When `stream: true`:
|
||||
|
||||
```text
|
||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
## Completions (Coming Soon)
|
||||
|
||||
### Create Completion
|
||||
|
||||
**Note: This endpoint is coming soon and not yet available.**
|
||||
|
||||
Generate text completion (legacy endpoint).
|
||||
|
||||
```http
|
||||
POST /v1/completions
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gpt-3.5-turbo-instruct",
|
||||
"prompt": "Once upon a time",
|
||||
"max_tokens": 50,
|
||||
"temperature": 0.7
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "cmpl-123",
|
||||
"object": "text_completion",
|
||||
"created": 1677652288,
|
||||
"model": "gpt-3.5-turbo-instruct",
|
||||
"choices": [{
|
||||
"text": " in a faraway land, there lived a brave knight...",
|
||||
"index": 0,
|
||||
"logprobs": null,
|
||||
"finish_reason": "length"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 4,
|
||||
"completion_tokens": 50,
|
||||
"total_tokens": 54
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Embeddings
|
||||
|
||||
### Create Embeddings (Coming Soon)
|
||||
|
||||
**Note: This endpoint is coming soon and not yet available.**
|
||||
|
||||
Generate vector representations of text.
|
||||
|
||||
```http
|
||||
POST /v1/embeddings
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "text-embedding-3-small",
|
||||
"input": "The quick brown fox jumps over the lazy dog",
|
||||
"encoding_format": "float"
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `model` | string | Yes | - | Embedding model ID |
|
||||
| `input` | string/array | Yes | - | Text(s) to embed |
|
||||
| `encoding_format` | string | No | "float" | Format: "float" or "base64" |
|
||||
| `dimensions` | integer | No | Model default | Output dimensions |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"object": "list",
|
||||
"data": [{
|
||||
"object": "embedding",
|
||||
"index": 0,
|
||||
"embedding": [0.0023064255, -0.009327292, ...]
|
||||
}],
|
||||
"model": "text-embedding-3-small",
|
||||
"usage": {
|
||||
"prompt_tokens": 9,
|
||||
"total_tokens": 9
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Images (Coming Soon)
|
||||
|
||||
### Create Image
|
||||
|
||||
**Note: This endpoint is coming soon and not yet available.**
|
||||
|
||||
Generate images from text prompts.
|
||||
|
||||
```http
|
||||
POST /v1/images/generations
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "dall-e-3",
|
||||
"prompt": "A white siamese cat wearing a space helmet",
|
||||
"n": 1,
|
||||
"size": "1024x1024",
|
||||
"quality": "standard"
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `model` | string | Yes | - | Model: dall-e-2, dall-e-3 |
|
||||
| `prompt` | string | Yes | - | Text description |
|
||||
| `n` | integer | No | 1 | Number of images |
|
||||
| `size` | string | No | "1024x1024" | Image dimensions |
|
||||
| `quality` | string | No | "standard" | Quality: standard, hd |
|
||||
| `style` | string | No | "vivid" | Style: vivid, natural |
|
||||
| `response_format` | string | No | "url" | Format: url, b64_json |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"created": 1677652288,
|
||||
"data": [{
|
||||
"url": "https://generated-image-url.com/image.png",
|
||||
"revised_prompt": "A white Siamese cat wearing a detailed space helmet..."
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## Audio (Coming Soon)
|
||||
|
||||
### Create Transcription
|
||||
|
||||
**Note: This endpoint is coming soon and not yet available.**
|
||||
|
||||
Convert audio to text.
|
||||
|
||||
```http
|
||||
POST /v1/audio/transcriptions
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**Form Data:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `file` | file | Yes | Audio file (mp3, mp4, mpeg, mpga, m4a, wav, webm) |
|
||||
| `model` | string | Yes | Model ID (whisper-1) |
|
||||
| `language` | string | No | Language code (ISO-639-1) |
|
||||
| `prompt` | string | No | Context prompt |
|
||||
| `response_format` | string | No | Format: json, text, srt, verbose_json, vtt |
|
||||
| `temperature` | number | No | Sampling temperature |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "Hello, this is the transcribed audio content."
|
||||
}
|
||||
```
|
||||
|
||||
### Create Translation
|
||||
|
||||
**Note: This endpoint is coming soon and not yet available.**
|
||||
|
||||
Translate audio to English.
|
||||
|
||||
```http
|
||||
POST /v1/audio/translations
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
Same parameters as transcription, but always translates to English.
|
||||
|
||||
## Models
|
||||
|
||||
### List Models
|
||||
|
||||
Get available models and pricing.
|
||||
|
||||
```http
|
||||
GET /v1/models
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "gpt-3.5-turbo",
|
||||
"object": "model",
|
||||
"created": 1677610602,
|
||||
"owned_by": "openai",
|
||||
"permission": [...],
|
||||
"root": "gpt-3.5-turbo",
|
||||
"parent": null,
|
||||
"pricing": {
|
||||
"prompt": 0.001,
|
||||
"completion": 0.002,
|
||||
"unit": "1k tokens"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Wallet Management
|
||||
|
||||
### Create Wallet (Coming Soon)
|
||||
|
||||
**Note: This endpoint is coming soon. Currently, you can use Cashu tokens directly as API keys.**
|
||||
|
||||
Create a new wallet with eCash deposit.
|
||||
|
||||
```http
|
||||
POST /v1/wallet/create
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"cashu_token": "cashuAeyJ0...",
|
||||
"admin_key": "optional-admin-key"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "sk-1234567890abcdef",
|
||||
"admin_key": "radmin_fedcba0987654321",
|
||||
"balance": 10000,
|
||||
"mint": "https://mint.example.com",
|
||||
"unit": "sat"
|
||||
}
|
||||
```
|
||||
|
||||
### Check Balance
|
||||
|
||||
Get current wallet balance.
|
||||
|
||||
```http
|
||||
GET /v1/wallet/balance
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"balance": 8500,
|
||||
"currency": "sat",
|
||||
"reserved": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Top Up Wallet
|
||||
|
||||
Add funds to existing wallet.
|
||||
|
||||
```http
|
||||
POST /v1/wallet/topup
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"cashu_token": "cashuAeyJ0..."
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"balance": 18500,
|
||||
"amount_added": 10000,
|
||||
"currency": "sat"
|
||||
}
|
||||
```
|
||||
|
||||
### Withdraw Funds
|
||||
|
||||
Withdraw balance as eCash.
|
||||
|
||||
```http
|
||||
POST /v1/wallet/withdraw
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"amount": 5000,
|
||||
"mint": "https://mint.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"cashu_token": "cashuAeyJ0...",
|
||||
"amount": 5000,
|
||||
"mint": "https://mint.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
## Provider Discovery
|
||||
|
||||
### List Providers
|
||||
|
||||
Get available upstream providers.
|
||||
|
||||
```http
|
||||
GET /v1/providers
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"name": "openai",
|
||||
"models": ["gpt-4", "gpt-3.5-turbo"],
|
||||
"endpoints": ["chat/completions", "completions"],
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Provider Info
|
||||
|
||||
Get specific provider details.
|
||||
|
||||
```http
|
||||
GET /v1/providers/{provider_name}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "openai",
|
||||
"display_name": "OpenAI",
|
||||
"description": "Official OpenAI API",
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-4",
|
||||
"name": "GPT-4",
|
||||
"context_window": 8192,
|
||||
"pricing": {
|
||||
"prompt": 0.03,
|
||||
"completion": 0.06,
|
||||
"unit": "1k tokens"
|
||||
}
|
||||
}
|
||||
],
|
||||
"endpoints": ["chat/completions", "completions", "embeddings"],
|
||||
"features": ["streaming", "function_calling"],
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
All endpoints are subject to rate limiting:
|
||||
|
||||
- **Per minute**: 60 requests
|
||||
- **Per hour**: 1000 requests
|
||||
- **Per day**: 10000 requests
|
||||
|
||||
Rate limit information is included in response headers.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Errors](errors.md) - Error handling reference
|
||||
- [Authentication](authentication.md) - Auth details
|
||||
- [Examples](../user-guide/using-api.md) - Code examples
|
||||
@@ -0,0 +1,592 @@
|
||||
# Error Handling
|
||||
|
||||
This guide covers error responses, codes, and handling strategies for the Routstr API.
|
||||
|
||||
## Error Response Format
|
||||
|
||||
All errors follow a consistent JSON structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "error_type",
|
||||
"message": "Human-readable error message",
|
||||
"code": "error_code",
|
||||
"details": {
|
||||
"additional": "context-specific information"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP Status Codes
|
||||
|
||||
| Status | Meaning | Common Causes |
|
||||
|--------|---------|---------------|
|
||||
| 400 | Bad Request | Invalid parameters, malformed JSON |
|
||||
| 401 | Unauthorized | Invalid or missing API key |
|
||||
| 402 | Payment Required | Insufficient balance |
|
||||
| 403 | Forbidden | Access denied to resource |
|
||||
| 404 | Not Found | Endpoint or resource doesn't exist |
|
||||
| 422 | Unprocessable Entity | Validation errors |
|
||||
| 429 | Too Many Requests | Rate limit exceeded |
|
||||
| 500 | Internal Server Error | Server-side error |
|
||||
| 502 | Bad Gateway | Upstream API error |
|
||||
| 503 | Service Unavailable | Temporary outage |
|
||||
|
||||
## Error Types
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
#### Invalid API Key
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "authentication_failed",
|
||||
"message": "Invalid API key provided",
|
||||
"code": "invalid_api_key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 401
|
||||
**Resolution:** Check API key format and validity
|
||||
|
||||
#### Expired API Key
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "authentication_failed",
|
||||
"message": "API key has expired",
|
||||
"code": "key_expired",
|
||||
"details": {
|
||||
"expired_at": "2024-01-01T00:00:00Z",
|
||||
"refund_available": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 401
|
||||
**Resolution:** Create new API key or contact admin for refund
|
||||
|
||||
#### Missing Authorization
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "authentication_failed",
|
||||
"message": "Authorization header required",
|
||||
"code": "missing_auth"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 401
|
||||
**Resolution:** Include `Authorization: Bearer {api_key}` header
|
||||
|
||||
### Payment Errors
|
||||
|
||||
#### Insufficient Balance
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "insufficient_balance",
|
||||
"message": "Insufficient balance for request",
|
||||
"code": "payment_required",
|
||||
"details": {
|
||||
"balance": 100,
|
||||
"required": 154,
|
||||
"shortfall": 54,
|
||||
"estimated_tokens": {
|
||||
"prompt": 50,
|
||||
"completion": 150
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 402
|
||||
**Resolution:** Top up API key balance
|
||||
|
||||
#### Invalid Token
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "payment_error",
|
||||
"message": "Invalid Cashu token",
|
||||
"code": "invalid_token",
|
||||
"details": {
|
||||
"reason": "Token already spent"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 400
|
||||
**Resolution:** Use a valid, unspent token
|
||||
|
||||
#### Mint Unavailable
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "payment_error",
|
||||
"message": "Cannot connect to Cashu mint",
|
||||
"code": "mint_unavailable",
|
||||
"details": {
|
||||
"mint_url": "https://mint.example.com",
|
||||
"retry_after": 60
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 503
|
||||
**Resolution:** Try again later or use different mint
|
||||
|
||||
### Validation Errors
|
||||
|
||||
#### Invalid Parameters
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "invalid_request",
|
||||
"message": "Invalid request parameters",
|
||||
"code": "validation_error",
|
||||
"details": {
|
||||
"errors": [
|
||||
{
|
||||
"field": "temperature",
|
||||
"message": "Must be between 0 and 2",
|
||||
"value": 3.5
|
||||
},
|
||||
{
|
||||
"field": "model",
|
||||
"message": "Model 'gpt-5' not found",
|
||||
"value": "gpt-5"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 422
|
||||
**Resolution:** Fix parameter values
|
||||
|
||||
#### Missing Required Fields
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "invalid_request",
|
||||
"message": "Missing required fields",
|
||||
"code": "missing_fields",
|
||||
"details": {
|
||||
"missing": ["model", "messages"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 400
|
||||
**Resolution:** Include all required fields
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
#### Rate Limit Exceeded
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "rate_limit_exceeded",
|
||||
"message": "Too many requests",
|
||||
"code": "rate_limit",
|
||||
"details": {
|
||||
"limit": 100,
|
||||
"window": "1 minute",
|
||||
"retry_after": 45
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 429
|
||||
**Headers:**
|
||||
|
||||
```
|
||||
X-RateLimit-Limit: 100
|
||||
X-RateLimit-Remaining: 0
|
||||
X-RateLimit-Reset: 1640995200
|
||||
Retry-After: 45
|
||||
```
|
||||
|
||||
**Resolution:** Wait for retry_after seconds
|
||||
|
||||
### Upstream Errors
|
||||
|
||||
#### Model Overloaded
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "upstream_error",
|
||||
"message": "Model is currently overloaded",
|
||||
"code": "model_overloaded",
|
||||
"details": {
|
||||
"model": "gpt-4",
|
||||
"retry_after": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 503
|
||||
**Resolution:** Retry request after delay
|
||||
|
||||
#### Upstream Timeout
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "upstream_error",
|
||||
"message": "Request to upstream API timed out",
|
||||
"code": "upstream_timeout",
|
||||
"details": {
|
||||
"timeout": 30,
|
||||
"endpoint": "chat/completions"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 504
|
||||
**Resolution:** Retry with shorter prompt or max_tokens
|
||||
|
||||
### Content Policy
|
||||
|
||||
#### Content Filtered
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "content_policy_violation",
|
||||
"message": "Content filtered due to policy violation",
|
||||
"code": "content_filtered",
|
||||
"details": {
|
||||
"reason": "harmful_content",
|
||||
"categories": ["violence", "hate"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 400
|
||||
**Resolution:** Modify prompt to comply with policies
|
||||
|
||||
## Error Handling Best Practices
|
||||
|
||||
### Retry Logic
|
||||
|
||||
Implement exponential backoff with jitter:
|
||||
|
||||
```python
|
||||
import time
|
||||
import random
|
||||
from typing import Optional, Callable
|
||||
|
||||
def retry_with_backoff(
|
||||
func: Callable,
|
||||
max_retries: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 60.0
|
||||
) -> Optional[Any]:
|
||||
"""Retry function with exponential backoff."""
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return func()
|
||||
except Exception as e:
|
||||
if attempt == max_retries - 1:
|
||||
raise
|
||||
|
||||
# Check if error is retryable
|
||||
if hasattr(e, 'status_code'):
|
||||
if e.status_code in [429, 502, 503, 504]:
|
||||
# Calculate delay with jitter
|
||||
delay = min(
|
||||
base_delay * (2 ** attempt) + random.uniform(0, 1),
|
||||
max_delay
|
||||
)
|
||||
|
||||
# Use retry_after if provided
|
||||
if hasattr(e, 'retry_after'):
|
||||
delay = e.retry_after
|
||||
|
||||
time.sleep(delay)
|
||||
else:
|
||||
# Non-retryable error
|
||||
raise
|
||||
```
|
||||
|
||||
### Error Categories
|
||||
|
||||
Group errors for handling:
|
||||
|
||||
```python
|
||||
class ErrorHandler:
|
||||
# Errors that should be retried
|
||||
RETRYABLE_ERRORS = {
|
||||
'rate_limit',
|
||||
'upstream_timeout',
|
||||
'model_overloaded',
|
||||
'mint_unavailable'
|
||||
}
|
||||
|
||||
# Errors requiring user action
|
||||
USER_ACTION_ERRORS = {
|
||||
'insufficient_balance',
|
||||
'invalid_api_key',
|
||||
'key_expired'
|
||||
}
|
||||
|
||||
# Errors requiring code changes
|
||||
CLIENT_ERRORS = {
|
||||
'validation_error',
|
||||
'missing_fields',
|
||||
'invalid_request'
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def handle_error(cls, error_response: dict) -> None:
|
||||
error_code = error_response['error']['code']
|
||||
|
||||
if error_code in cls.RETRYABLE_ERRORS:
|
||||
# Implement retry logic
|
||||
pass
|
||||
elif error_code in cls.USER_ACTION_ERRORS:
|
||||
# Alert user
|
||||
pass
|
||||
elif error_code in cls.CLIENT_ERRORS:
|
||||
# Log for debugging
|
||||
pass
|
||||
```
|
||||
|
||||
### Graceful Degradation
|
||||
|
||||
Handle errors without breaking application flow:
|
||||
|
||||
```python
|
||||
async def get_ai_response(prompt: str) -> str:
|
||||
"""Get AI response with fallback handling."""
|
||||
try:
|
||||
# Try primary model
|
||||
response = await client.chat.completions.create(
|
||||
model="gpt-4",
|
||||
messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
|
||||
except InsufficientBalanceError:
|
||||
# Fall back to cheaper model
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=100 # Limit tokens
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
except Exception as e:
|
||||
logger.error(f"Fallback failed: {e}")
|
||||
return "Service temporarily unavailable"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
return "An error occurred processing your request"
|
||||
```
|
||||
|
||||
### Logging Errors
|
||||
|
||||
Structure error logs for debugging:
|
||||
|
||||
```python
|
||||
import logging
|
||||
import json
|
||||
|
||||
def log_api_error(error_response: dict, context: dict) -> None:
|
||||
"""Log API errors with context."""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
error_data = {
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'error': error_response['error'],
|
||||
'context': {
|
||||
'endpoint': context.get('endpoint'),
|
||||
'api_key_id': context.get('api_key_id'),
|
||||
'request_id': context.get('request_id'),
|
||||
'model': context.get('model')
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(
|
||||
"API Error",
|
||||
extra={'structured_data': json.dumps(error_data)}
|
||||
)
|
||||
```
|
||||
|
||||
### User-Friendly Messages
|
||||
|
||||
Map technical errors to user messages:
|
||||
|
||||
```python
|
||||
ERROR_MESSAGES = {
|
||||
'insufficient_balance': "Your account balance is too low. Please add funds to continue.",
|
||||
'invalid_api_key': "Invalid API key. Please check your configuration.",
|
||||
'rate_limit': "Too many requests. Please wait a moment and try again.",
|
||||
'model_overloaded': "The AI service is busy. Please try again in a few seconds.",
|
||||
'validation_error': "Invalid request. Please check your input and try again."
|
||||
}
|
||||
|
||||
def get_user_message(error_code: str) -> str:
|
||||
"""Get user-friendly error message."""
|
||||
return ERROR_MESSAGES.get(
|
||||
error_code,
|
||||
"An unexpected error occurred. Please try again later."
|
||||
)
|
||||
```
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### Handling Balance Errors
|
||||
|
||||
```python
|
||||
async def make_request_with_balance_check():
|
||||
try:
|
||||
# Check balance first
|
||||
balance_info = await client.get("/v1/wallet/balance")
|
||||
|
||||
# Estimate cost
|
||||
estimated_cost = calculate_cost(model, prompt_length)
|
||||
|
||||
if balance_info['balance'] < estimated_cost * 1.1: # 10% buffer
|
||||
# Proactively top up
|
||||
await top_up_balance()
|
||||
|
||||
# Make request
|
||||
return await client.chat.completions.create(...)
|
||||
|
||||
except InsufficientBalanceError as e:
|
||||
# Handle insufficient balance
|
||||
shortfall = e.details['shortfall']
|
||||
await top_up_balance(amount=shortfall * 2)
|
||||
# Retry request
|
||||
```
|
||||
|
||||
### Handling Rate Limits
|
||||
|
||||
```python
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class RateLimitTracker:
|
||||
def __init__(self):
|
||||
self.reset_times = {}
|
||||
|
||||
def is_limited(self, endpoint: str) -> bool:
|
||||
reset_time = self.reset_times.get(endpoint)
|
||||
if reset_time and datetime.now() < reset_time:
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_limit(self, endpoint: str, reset_timestamp: int):
|
||||
self.reset_times[endpoint] = datetime.fromtimestamp(reset_timestamp)
|
||||
|
||||
def wait_time(self, endpoint: str) -> float:
|
||||
reset_time = self.reset_times.get(endpoint)
|
||||
if reset_time:
|
||||
return max(0, (reset_time - datetime.now()).total_seconds())
|
||||
return 0
|
||||
```
|
||||
|
||||
## Testing Error Handling
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
|
||||
async def test_insufficient_balance_handling():
|
||||
# Mock API client
|
||||
mock_client = Mock()
|
||||
mock_client.chat.completions.create.side_effect = InsufficientBalanceError(
|
||||
required=100,
|
||||
available=50
|
||||
)
|
||||
|
||||
# Test error handling
|
||||
handler = ErrorHandler(mock_client)
|
||||
result = await handler.safe_request(
|
||||
model="gpt-4",
|
||||
messages=[{"role": "user", "content": "test"}]
|
||||
)
|
||||
|
||||
# Verify fallback behavior
|
||||
assert result.fallback_used is True
|
||||
assert result.model == "gpt-3.5-turbo"
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```python
|
||||
async def test_real_error_scenarios():
|
||||
# Test with invalid API key
|
||||
invalid_client = OpenAI(
|
||||
api_key="sk-invalid",
|
||||
base_url=test_url
|
||||
)
|
||||
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
await invalid_client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "test"}]
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "invalid_api_key" in str(exc_info.value)
|
||||
```
|
||||
|
||||
## Monitoring Errors
|
||||
|
||||
Track error rates and patterns:
|
||||
|
||||
```python
|
||||
class ErrorMetrics:
|
||||
def __init__(self):
|
||||
self.error_counts = defaultdict(int)
|
||||
self.error_timestamps = defaultdict(list)
|
||||
|
||||
def record_error(self, error_code: str):
|
||||
self.error_counts[error_code] += 1
|
||||
self.error_timestamps[error_code].append(datetime.now())
|
||||
|
||||
def get_error_rate(self, error_code: str, window_minutes: int = 60) -> float:
|
||||
cutoff = datetime.now() - timedelta(minutes=window_minutes)
|
||||
recent_errors = [
|
||||
ts for ts in self.error_timestamps[error_code]
|
||||
if ts > cutoff
|
||||
]
|
||||
return len(recent_errors) / window_minutes
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Authentication](authentication.md) - Auth error details
|
||||
- [Endpoints](endpoints.md) - Endpoint-specific errors
|
||||
- [Examples](../user-guide/using-api.md) - Error handling examples
|
||||
@@ -0,0 +1,365 @@
|
||||
# API Reference Overview
|
||||
|
||||
Routstr Core provides a complete OpenAI-compatible API with additional endpoints for payment management. This reference covers all available endpoints, authentication methods, and response formats.
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
https://api.routstr.com/v1
|
||||
```
|
||||
|
||||
All API endpoints are prefixed with `/v1` for versioning.
|
||||
|
||||
## Authentication
|
||||
|
||||
Routstr uses API keys for authentication. Include your key in the Authorization header:
|
||||
|
||||
```bash
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
### API Key Format
|
||||
|
||||
- Prefix: `sk-`
|
||||
- Length: 32 characters
|
||||
- Example: `sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p`
|
||||
|
||||
### Cashu Tokens as Authentication
|
||||
|
||||
You can also use a Cashu eCash token directly in the `Authorization` header. The server hashes the token internally; this hash represents your API key identity and carries the token's balance.
|
||||
|
||||
```bash
|
||||
Authorization: Bearer cashuAeyJ0b2tlbiI6W3...
|
||||
```
|
||||
|
||||
## Content Types
|
||||
|
||||
### Request
|
||||
|
||||
- **Required**: `Content-Type: application/json`
|
||||
- **Encoding**: UTF-8
|
||||
- **Maximum Size**: 10MB (configurable)
|
||||
|
||||
### Response
|
||||
|
||||
- **Type**: `application/json` or `text/event-stream` (for streaming)
|
||||
- **Encoding**: UTF-8
|
||||
- **Compression**: gzip (if accepted)
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Rate limits are applied per API key:
|
||||
|
||||
- **Requests**: 1000 per minute
|
||||
- **Tokens**: 1,000,000 per hour
|
||||
- **Concurrent**: 10 simultaneous requests
|
||||
|
||||
Rate limit headers:
|
||||
|
||||
```
|
||||
X-RateLimit-Limit: 1000
|
||||
X-RateLimit-Remaining: 999
|
||||
X-RateLimit-Reset: 1640995200
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
All errors follow a consistent format:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "insufficient_balance",
|
||||
"message": "Insufficient balance for request",
|
||||
"code": "payment_required",
|
||||
"details": {
|
||||
"required": 154,
|
||||
"available": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Types
|
||||
|
||||
| Type | Status Code | Description |
|
||||
|------|-------------|-------------|
|
||||
| `invalid_request` | 400 | Malformed request |
|
||||
| `authentication_failed` | 401 | Invalid or missing API key |
|
||||
| `insufficient_balance` | 402 | Not enough balance |
|
||||
| `forbidden` | 403 | Access denied |
|
||||
| `not_found` | 404 | Resource not found |
|
||||
| `rate_limit_exceeded` | 429 | Too many requests |
|
||||
| `internal_error` | 500 | Server error |
|
||||
| `upstream_error` | 502 | Upstream API error |
|
||||
|
||||
## Endpoint Categories
|
||||
|
||||
### AI/ML Endpoints
|
||||
|
||||
Standard OpenAI-compatible endpoints:
|
||||
|
||||
- **Chat Completions**: `/v1/chat/completions`
|
||||
- **Completions**: `/v1/completions` *(Coming soon)*
|
||||
- **Embeddings**: `/v1/embeddings` *(Coming soon)*
|
||||
- **Images**: `/v1/images/generations` *(Coming soon)*
|
||||
- **Audio**: `/v1/audio/transcriptions` *(Coming soon)*
|
||||
- **Models**: `/v1/models`
|
||||
|
||||
### Payment Endpoints
|
||||
|
||||
Routstr-specific payment management:
|
||||
|
||||
- **Wallet**: `/v1/wallet/*`
|
||||
- **Balance**: `/v1/balance`
|
||||
- **Node Info**: `/v1/info`
|
||||
|
||||
### Admin Endpoints
|
||||
|
||||
Protected administrative functions:
|
||||
|
||||
- **Dashboard**: `/admin/`
|
||||
- **API Management**: `/admin/api/*`
|
||||
|
||||
## Request Headers
|
||||
|
||||
### Standard Headers
|
||||
|
||||
| Header | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `Authorization` | Yes | Bearer token with API key |
|
||||
| `Content-Type` | Yes | Must be `application/json` |
|
||||
| `Accept` | No | Response format preference |
|
||||
| `Accept-Encoding` | No | Compression support |
|
||||
| `X-Request-ID` | No | Client-provided request ID |
|
||||
|
||||
### Custom Headers
|
||||
|
||||
| Header | Description |
|
||||
|--------|-------------|
|
||||
| `X-Routstr-Version` | API version override |
|
||||
| `X-Cashu` | eCash token for per-request payment |
|
||||
| `X-Max-Cost` | Maximum acceptable cost in sats |
|
||||
|
||||
## Response Headers
|
||||
|
||||
### Standard Headers
|
||||
|
||||
| Header | Description |
|
||||
|--------|-------------|
|
||||
| `Content-Type` | Response format |
|
||||
| `Content-Length` | Response size |
|
||||
| `X-Routstr-Request-ID` | Unique request identifier |
|
||||
| `X-Routstr-Version` | API version used |
|
||||
|
||||
### Cost Headers
|
||||
|
||||
| Header | Description |
|
||||
|--------|-------------|
|
||||
| `X-Routstr-Cost` | Request cost in sats |
|
||||
| `X-Routstr-Balance` | Remaining balance |
|
||||
| `X-Cashu` | Change token (if applicable) |
|
||||
|
||||
## Streaming Responses
|
||||
|
||||
For endpoints supporting streaming, responses use Server-Sent Events:
|
||||
|
||||
```
|
||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":" there"},"finish_reason":null}]}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
## OpenAPI Specification
|
||||
|
||||
The complete OpenAPI 3.0 specification is available at:
|
||||
|
||||
```
|
||||
GET /openapi.json
|
||||
```
|
||||
|
||||
Interactive documentation:
|
||||
|
||||
```
|
||||
GET /docs # Swagger UI
|
||||
GET /redoc # ReDoc
|
||||
```
|
||||
|
||||
## SDK Support
|
||||
|
||||
Routstr is compatible with official OpenAI SDKs:
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="https://your-node.com/v1"
|
||||
)
|
||||
```
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
```javascript
|
||||
import OpenAI from 'openai';
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: 'sk-...',
|
||||
baseURL: 'https://your-node.com/v1'
|
||||
});
|
||||
```
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
curl https://your-node.com/v1/chat/completions \
|
||||
-H "Authorization: Bearer sk-..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"Hello"}]}'
|
||||
```
|
||||
|
||||
## Webhook Support
|
||||
|
||||
Configure webhooks for events:
|
||||
|
||||
```json
|
||||
POST /v1/webhooks
|
||||
{
|
||||
"url": "https://your-app.com/webhook",
|
||||
"events": ["balance.low", "key.expired"],
|
||||
"secret": "whsec_your_secret"
|
||||
}
|
||||
```
|
||||
|
||||
Events are sent with signature verification:
|
||||
|
||||
```
|
||||
X-Webhook-Signature: sha256=...
|
||||
```
|
||||
|
||||
## API Versioning
|
||||
|
||||
- Current version: `v1`
|
||||
- Version in URL path: `/v1/endpoint`
|
||||
- Override with header: `X-Routstr-Version: v2`
|
||||
- Deprecation notices: 6 months
|
||||
|
||||
## Status Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 200 | Success |
|
||||
| 201 | Created |
|
||||
| 204 | No content |
|
||||
| 400 | Bad request |
|
||||
| 401 | Unauthorized |
|
||||
| 402 | Payment required |
|
||||
| 403 | Forbidden |
|
||||
| 404 | Not found |
|
||||
| 429 | Rate limited |
|
||||
| 500 | Server error |
|
||||
| 502 | Upstream error |
|
||||
| 503 | Service unavailable |
|
||||
|
||||
## CORS Support
|
||||
|
||||
CORS is enabled with configurable origins:
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin: *
|
||||
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
|
||||
Access-Control-Allow-Headers: Authorization, Content-Type
|
||||
Access-Control-Max-Age: 86400
|
||||
```
|
||||
|
||||
## Compression
|
||||
|
||||
Responses are compressed with gzip when:
|
||||
|
||||
- Client sends `Accept-Encoding: gzip`
|
||||
- Response is larger than 1KB
|
||||
- Content type is compressible
|
||||
|
||||
## Pagination
|
||||
|
||||
List endpoints support pagination:
|
||||
|
||||
```
|
||||
GET /v1/transactions?limit=50&offset=100
|
||||
```
|
||||
|
||||
Response includes pagination metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [...],
|
||||
"has_more": true,
|
||||
"total": 500,
|
||||
"limit": 50,
|
||||
"offset": 100
|
||||
}
|
||||
```
|
||||
|
||||
## Field Filtering
|
||||
|
||||
Select specific fields in responses:
|
||||
|
||||
```
|
||||
GET /v1/models?fields=id,name,pricing
|
||||
```
|
||||
|
||||
## Batch Requests
|
||||
|
||||
Process multiple operations in one request:
|
||||
|
||||
```json
|
||||
POST /v1/batch
|
||||
{
|
||||
"requests": [
|
||||
{"method": "POST", "endpoint": "/chat/completions", "body": {...}},
|
||||
{"method": "GET", "endpoint": "/models"},
|
||||
{"method": "GET", "endpoint": "/balance"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Idempotency
|
||||
|
||||
Prevent duplicate operations:
|
||||
|
||||
```
|
||||
Idempotency-Key: unique-request-id
|
||||
```
|
||||
|
||||
Keys are stored for 24 hours.
|
||||
|
||||
## Health Check
|
||||
|
||||
Monitor service status:
|
||||
|
||||
```
|
||||
GET /health
|
||||
|
||||
Response:
|
||||
{
|
||||
"status": "healthy",
|
||||
"version": "0.1.1b",
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"checks": {
|
||||
"database": "ok",
|
||||
"upstream": "ok",
|
||||
"mint": "ok"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Authentication](authentication.md) - Detailed auth guide
|
||||
- [Endpoints](endpoints.md) - Complete endpoint reference
|
||||
- [Errors](errors.md) - Error handling guide
|
||||
- [Examples](../user-guide/using-api.md) - Code examples
|
||||
@@ -0,0 +1,381 @@
|
||||
# Architecture Overview
|
||||
|
||||
This document describes the high-level architecture of Routstr Core, helping contributors understand how the system works.
|
||||
|
||||
## System Overview
|
||||
|
||||
Routstr Core is a FastAPI-based reverse proxy that adds Bitcoin micropayments to OpenAI-compatible APIs.
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "External Services"
|
||||
Client[API Client]
|
||||
Mint[Cashu Mint]
|
||||
Provider[AI Provider]
|
||||
Nostr[Nostr Relays]
|
||||
end
|
||||
|
||||
subgraph "Routstr Core"
|
||||
API[FastAPI Server]
|
||||
Auth[Auth Module]
|
||||
Payment[Payment Module]
|
||||
Proxy[Proxy Module]
|
||||
DB[(SQLite DB)]
|
||||
|
||||
API --> Auth
|
||||
Auth --> Payment
|
||||
Auth --> DB
|
||||
Payment --> Proxy
|
||||
Proxy --> Provider
|
||||
Payment --> Mint
|
||||
API --> Nostr
|
||||
end
|
||||
|
||||
Client --> API
|
||||
```
|
||||
|
||||
## Core Components
|
||||
|
||||
### FastAPI Application
|
||||
|
||||
The main application is initialized in `routstr/core/main.py`:
|
||||
|
||||
- **Lifespan Management**: Handles startup/shutdown tasks
|
||||
- **Middleware**: CORS, logging, error handling
|
||||
- **Routers**: Modular endpoint organization
|
||||
- **Background Tasks**: Price updates, automatic payouts
|
||||
|
||||
### Authentication System
|
||||
|
||||
Located in `routstr/auth.py`, handles:
|
||||
|
||||
- **API Key Validation**: Hashed key storage and lookup
|
||||
- **Balance Checking**: Ensures sufficient funds
|
||||
- **Rate Limiting**: Optional request throttling
|
||||
- **Token Redemption**: Converts eCash to balance
|
||||
|
||||
### Payment Processing
|
||||
|
||||
The `routstr/payment/` module manages:
|
||||
|
||||
- **Cost Calculation**: Token-based or fixed pricing
|
||||
- **Model Pricing**: Dynamic pricing from models.json
|
||||
- **Currency Conversion**: BTC/USD rate management
|
||||
- **Fee Application**: Exchange and provider fees
|
||||
|
||||
### Request Proxying
|
||||
|
||||
`routstr/proxy.py` handles:
|
||||
|
||||
- **Request Forwarding**: Preserves headers and body
|
||||
- **Response Streaming**: Efficient memory usage
|
||||
- **Usage Tracking**: Counts tokens and costs
|
||||
- **Error Handling**: Graceful upstream failures
|
||||
|
||||
### Database Layer
|
||||
|
||||
Using SQLModel in `routstr/core/db.py`:
|
||||
|
||||
```python
|
||||
# Core models
|
||||
APIKey:
|
||||
- id: Primary key
|
||||
- key_hash: Hashed API key
|
||||
- balance: Current balance (msats)
|
||||
- created_at: Timestamp
|
||||
- metadata: JSON field
|
||||
|
||||
Transaction:
|
||||
- id: Primary key
|
||||
- api_key_id: Foreign key
|
||||
- amount: Transaction amount
|
||||
- type: deposit/usage/withdrawal
|
||||
- timestamp: When occurred
|
||||
```
|
||||
|
||||
## Request Flow
|
||||
|
||||
### Standard API Request
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant R as Routstr
|
||||
participant D as Database
|
||||
participant P as AI Provider
|
||||
|
||||
C->>R: API Request + Key
|
||||
R->>D: Validate Key
|
||||
D-->>R: Key Info + Balance
|
||||
R->>R: Check Balance
|
||||
R->>P: Forward Request
|
||||
P-->>R: AI Response
|
||||
R->>D: Deduct Cost
|
||||
R-->>C: Return Response
|
||||
```
|
||||
|
||||
### Payment Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant R as Routstr
|
||||
participant W as Wallet Module
|
||||
participant M as Cashu Mint
|
||||
participant D as Database
|
||||
|
||||
C->>R: Create Key Request + Token
|
||||
R->>W: Validate Token
|
||||
W->>M: Verify with Mint
|
||||
M-->>W: Token Valid
|
||||
W-->>R: Token Amount
|
||||
R->>D: Create Key + Balance
|
||||
R-->>C: Return API Key
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Async Architecture
|
||||
|
||||
Everything is async for maximum performance:
|
||||
|
||||
```python
|
||||
async def handle_request(request: Request) -> Response:
|
||||
# Non-blocking database queries
|
||||
api_key = await get_api_key(request.headers["Authorization"])
|
||||
|
||||
# Concurrent operations
|
||||
balance_check, rate_limit = await asyncio.gather(
|
||||
check_balance(api_key),
|
||||
check_rate_limit(api_key)
|
||||
)
|
||||
|
||||
# Stream response without blocking
|
||||
async for chunk in proxy_request(request):
|
||||
yield chunk
|
||||
```
|
||||
|
||||
### 2. Modular Design
|
||||
|
||||
Components are loosely coupled:
|
||||
|
||||
- **Routers**: Separate files for different endpoints
|
||||
- **Dependencies**: Injected via FastAPI's DI system
|
||||
- **Models**: Shared data structures
|
||||
- **Services**: Business logic separated from routes
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
Graceful degradation and clear error messages:
|
||||
|
||||
```python
|
||||
class RoustrError(Exception):
|
||||
"""Base exception with structured error response"""
|
||||
status_code: int = 500
|
||||
error_type: str = "internal_error"
|
||||
|
||||
class InsufficientBalanceError(RoustrError):
|
||||
status_code = 402
|
||||
error_type = "insufficient_balance"
|
||||
```
|
||||
|
||||
### 4. Database Migrations
|
||||
|
||||
Using Alembic for schema management:
|
||||
|
||||
- Auto-migrations on startup
|
||||
- Version control for schema changes
|
||||
- Rollback capability
|
||||
- Zero-downtime updates
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### API Key Security
|
||||
|
||||
- **Storage**: SHA-256 hashed keys
|
||||
- **Generation**: Cryptographically secure random
|
||||
- **Validation**: Constant-time comparison
|
||||
- **Rotation**: Support for key expiry
|
||||
|
||||
### Payment Security
|
||||
|
||||
- **Token Validation**: Cryptographic verification
|
||||
- **Double-Spend Prevention**: Mint verification
|
||||
- **Balance Protection**: Atomic transactions
|
||||
- **Audit Trail**: All transactions logged
|
||||
|
||||
### Network Security
|
||||
|
||||
- **HTTPS**: Enforced in production
|
||||
- **CORS**: Configurable origins
|
||||
- **Rate Limiting**: Per-key limits
|
||||
- **Input Validation**: Pydantic models
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
```python
|
||||
# Model pricing cache
|
||||
@lru_cache(maxsize=100)
|
||||
def get_model_price(model_id: str) -> ModelPrice:
|
||||
return MODELS.get(model_id)
|
||||
|
||||
# Balance cache with TTL
|
||||
balance_cache = TTLCache(maxsize=1000, ttl=60)
|
||||
```
|
||||
|
||||
### Database Optimization
|
||||
|
||||
- **Connection Pooling**: Reuse connections
|
||||
- **Indexed Queries**: Key lookups are O(1)
|
||||
- **Batch Operations**: Group updates
|
||||
- **Async I/O**: Non-blocking queries
|
||||
|
||||
### Streaming Responses
|
||||
|
||||
Efficient memory usage for large responses:
|
||||
|
||||
```python
|
||||
async def stream_response(upstream_response):
|
||||
async for chunk in upstream_response.aiter_bytes():
|
||||
# Process chunk without loading full response
|
||||
yield process_chunk(chunk)
|
||||
```
|
||||
|
||||
## Extension Points
|
||||
|
||||
### Adding New Endpoints
|
||||
|
||||
1. Create router module
|
||||
2. Define Pydantic models
|
||||
3. Implement business logic
|
||||
4. Register with main app
|
||||
5. Add tests
|
||||
|
||||
### Custom Pricing Models
|
||||
|
||||
1. Extend `ModelPrice` class
|
||||
2. Implement calculation logic
|
||||
3. Add to pricing registry
|
||||
4. Update configuration
|
||||
|
||||
### Payment Methods
|
||||
|
||||
1. Create payment handler
|
||||
2. Implement validation
|
||||
3. Add to payment router
|
||||
4. Update balance logic
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- Mock external dependencies
|
||||
- Test business logic in isolation
|
||||
- Fast execution (< 1 second per test)
|
||||
- High coverage target (> 80%)
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Test component interactions
|
||||
- Use test database
|
||||
- Mock external services
|
||||
- Verify end-to-end flows
|
||||
|
||||
### Performance Tests
|
||||
|
||||
- Load testing with locust
|
||||
- Memory profiling
|
||||
- Database query optimization
|
||||
- Response time benchmarks
|
||||
|
||||
## Monitoring and Observability
|
||||
|
||||
### Structured Logging
|
||||
|
||||
```python
|
||||
logger.info("api_request", extra={
|
||||
"request_id": request_id,
|
||||
"api_key": api_key_id,
|
||||
"endpoint": endpoint,
|
||||
"model": model,
|
||||
"tokens": token_count,
|
||||
"cost_sats": cost,
|
||||
"duration_ms": duration
|
||||
})
|
||||
```
|
||||
|
||||
### Metrics Collection
|
||||
|
||||
Key metrics tracked:
|
||||
|
||||
- Request rate by endpoint
|
||||
- Token usage by model
|
||||
- Balance changes
|
||||
- Error rates
|
||||
- Response times
|
||||
|
||||
### Health Checks
|
||||
|
||||
```python
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": __version__,
|
||||
"database": await check_db(),
|
||||
"upstream": await check_upstream(),
|
||||
"mints": await check_mints()
|
||||
}
|
||||
```
|
||||
|
||||
## Deployment Architecture
|
||||
|
||||
### Container Structure
|
||||
|
||||
```dockerfile
|
||||
# Multi-stage build
|
||||
FROM python:3.11-slim AS builder
|
||||
# Install dependencies
|
||||
|
||||
FROM python:3.11-slim
|
||||
# Copy only runtime needs
|
||||
# Run as non-root user
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
- **Development**: Local SQLite, debug logging
|
||||
- **Testing**: In-memory database, mock services
|
||||
- **Production**: Persistent storage, structured logs
|
||||
|
||||
### Scaling Considerations
|
||||
|
||||
- **Horizontal**: Multiple instances behind load balancer
|
||||
- **Vertical**: Async handles high concurrency
|
||||
- **Database**: Consider PostgreSQL for scale
|
||||
- **Caching**: Redis for distributed cache
|
||||
|
||||
## Future Architecture
|
||||
|
||||
### Planned Improvements
|
||||
|
||||
1. **WebSocket Support**: Real-time balance updates
|
||||
2. **Plugin System**: Extensible pricing/auth
|
||||
3. **Multi-Region**: Geographic distribution
|
||||
4. **Event Sourcing**: Complete audit trail
|
||||
|
||||
### Technical Debt
|
||||
|
||||
Areas for improvement:
|
||||
|
||||
- Database query optimization
|
||||
- Response caching layer
|
||||
- Metric aggregation
|
||||
- API versioning strategy
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Review [Code Structure](code-structure.md) for detailed organization
|
||||
- See [Testing Guide](testing.md) for test architecture
|
||||
@@ -0,0 +1,493 @@
|
||||
# Code Structure
|
||||
|
||||
This guide provides a detailed overview of Routstr Core's codebase organization and key modules.
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
routstr-core/
|
||||
├── routstr/ # Main application package
|
||||
│ ├── __init__.py # Package initialization, loads .env
|
||||
│ ├── auth.py # Authentication and authorization
|
||||
│ ├── balance.py # Balance management endpoints
|
||||
│ ├── discovery.py # Nostr relay discovery
|
||||
│ ├── proxy.py # Request proxying logic
|
||||
│ ├── wallet.py # Cashu wallet operations
|
||||
│ │
|
||||
│ ├── core/ # Core infrastructure
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── admin.py # Admin dashboard and API
|
||||
│ │ ├── db.py # Database models and connection
|
||||
│ │ ├── exceptions.py # Custom exception classes
|
||||
│ │ ├── logging.py # Structured logging setup
|
||||
│ │ ├── main.py # FastAPI app initialization
|
||||
│ │ └── middleware.py # HTTP middleware components
|
||||
│ │
|
||||
│ └── payment/ # Payment processing
|
||||
│ ├── __init__.py
|
||||
│ ├── cost_calculation.py # Usage cost calculation
|
||||
│ ├── helpers.py # Payment utilities
|
||||
│ ├── lnurl.py # Lightning URL support
|
||||
│ ├── models.py # Model pricing management
|
||||
│ ├── price.py # BTC/USD price handling
|
||||
│ └── x_cashu.py # Cashu header protocol
|
||||
│
|
||||
├── tests/ # Test suite
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # Pytest configuration
|
||||
│ ├── unit/ # Unit tests
|
||||
│ └── integration/ # Integration tests
|
||||
│
|
||||
├── migrations/ # Alembic database migrations
|
||||
│ ├── alembic.ini
|
||||
│ ├── env.py
|
||||
│ ├── script.py.mako
|
||||
│ └── versions/ # Migration files
|
||||
│
|
||||
├── scripts/ # Utility scripts
|
||||
│ └── models_meta.py # Fetch model pricing
|
||||
│
|
||||
├── docs/ # Documentation
|
||||
├── logs/ # Application logs (git ignored)
|
||||
│
|
||||
├── .github/ # GitHub Actions workflows
|
||||
├── .env.example # Environment variable template
|
||||
├── .gitignore # Git ignore rules
|
||||
├── .dockerignore # Docker ignore rules
|
||||
├── Dockerfile # Container definition
|
||||
├── Makefile # Development commands
|
||||
├── README.md # Project overview
|
||||
├── alembic.ini # Migration configuration
|
||||
├── compose.yml # Docker Compose setup
|
||||
├── compose.testing.yml # Testing environment
|
||||
├── pyproject.toml # Project configuration
|
||||
└── uv.lock # Locked dependencies
|
||||
```
|
||||
|
||||
## Key Modules
|
||||
|
||||
### Application Entry Point
|
||||
|
||||
#### `routstr/__init__.py`
|
||||
|
||||
```python
|
||||
# Loads environment variables
|
||||
import dotenv
|
||||
dotenv.load_dotenv()
|
||||
|
||||
# Exports FastAPI app
|
||||
from .core.main import app as fastapi_app
|
||||
```
|
||||
|
||||
#### `routstr/core/main.py`
|
||||
|
||||
```python
|
||||
# FastAPI application setup
|
||||
app = FastAPI(
|
||||
title="Routstr Node",
|
||||
lifespan=lifespan, # Manages startup/shutdown
|
||||
)
|
||||
|
||||
# Middleware registration
|
||||
app.add_middleware(CORSMiddleware, ...)
|
||||
app.add_middleware(LoggingMiddleware)
|
||||
|
||||
# Router inclusion
|
||||
app.include_router(admin_router)
|
||||
app.include_router(balance_router)
|
||||
app.include_router(proxy_router)
|
||||
```
|
||||
|
||||
### Authentication Module
|
||||
|
||||
#### `routstr/auth.py`
|
||||
|
||||
Handles API key validation and authorization:
|
||||
|
||||
```python
|
||||
class APIKeyAuth:
|
||||
"""FastAPI dependency for API key authentication"""
|
||||
|
||||
async def __call__(self, request: Request) -> APIKey:
|
||||
# Extract and validate API key
|
||||
# Check balance
|
||||
# Return authenticated key object
|
||||
|
||||
# Usage in routes:
|
||||
@router.get("/protected")
|
||||
async def protected_route(api_key: APIKey = Depends(APIKeyAuth())):
|
||||
pass
|
||||
```
|
||||
|
||||
Key functions:
|
||||
|
||||
- `create_api_key()` - Generate new API keys
|
||||
- `validate_api_key()` - Verify and retrieve key
|
||||
- `check_balance()` - Ensure sufficient funds
|
||||
- `update_last_used()` - Track usage
|
||||
|
||||
### Payment Processing
|
||||
|
||||
#### `routstr/payment/cost_calculation.py`
|
||||
|
||||
Calculates request costs:
|
||||
|
||||
```python
|
||||
def calculate_request_cost(
|
||||
model: str,
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
**kwargs
|
||||
) -> CostData:
|
||||
"""Calculate cost in millisatoshis"""
|
||||
# Model-based or fixed pricing
|
||||
# Token counting
|
||||
# Fee application
|
||||
# Currency conversion
|
||||
```
|
||||
|
||||
#### `routstr/payment/models.py`
|
||||
|
||||
Manages model pricing data:
|
||||
|
||||
```python
|
||||
class ModelPrice:
|
||||
id: str
|
||||
name: str
|
||||
pricing: dict[str, float] # USD prices
|
||||
context_length: int
|
||||
|
||||
# Global model registry
|
||||
MODELS: dict[str, ModelPrice] = load_models()
|
||||
|
||||
# Dynamic price updates
|
||||
async def update_sats_pricing():
|
||||
"""Background task to update BTC prices"""
|
||||
```
|
||||
|
||||
#### `routstr/payment/x_cashu.py`
|
||||
|
||||
Implements Cashu payment protocol:
|
||||
|
||||
```python
|
||||
class XCashuHandler:
|
||||
"""Handle x-cashu header payments"""
|
||||
|
||||
async def process_request_payment(
|
||||
self,
|
||||
token: str,
|
||||
estimated_cost: int
|
||||
) -> PaymentResult:
|
||||
# Validate token
|
||||
# Check minimum amount
|
||||
# Process payment
|
||||
# Generate change
|
||||
```
|
||||
|
||||
### Request Proxying
|
||||
|
||||
#### `routstr/proxy.py`
|
||||
|
||||
Core proxy functionality:
|
||||
|
||||
```python
|
||||
@router.api_route("/{path:path}", methods=ALL_METHODS)
|
||||
async def proxy_request(
|
||||
request: Request,
|
||||
path: str,
|
||||
api_key: APIKey = Depends(APIKeyAuth())
|
||||
) -> Response:
|
||||
"""Forward requests to upstream provider"""
|
||||
# Build upstream request
|
||||
# Stream response
|
||||
# Track usage
|
||||
# Deduct costs
|
||||
```
|
||||
|
||||
Key features:
|
||||
|
||||
- Streaming support
|
||||
- Header preservation
|
||||
- Error handling
|
||||
- Usage tracking
|
||||
|
||||
### Database Layer
|
||||
|
||||
#### `routstr/core/db.py`
|
||||
|
||||
SQLModel definitions:
|
||||
|
||||
```python
|
||||
class APIKey(SQLModel, table=True):
|
||||
id: int | None = Field(primary_key=True)
|
||||
key_hash: str = Field(index=True, unique=True)
|
||||
balance: int # millisatoshis
|
||||
total_deposited: int = 0
|
||||
total_spent: int = 0
|
||||
created_at: datetime
|
||||
expires_at: datetime | None = None
|
||||
metadata: dict = Field(default_factory=dict, sa_column=Column(JSON))
|
||||
|
||||
class Transaction(SQLModel, table=True):
|
||||
id: int | None = Field(primary_key=True)
|
||||
api_key_id: int = Field(foreign_key="apikey.id")
|
||||
amount: int # can be negative
|
||||
balance_after: int
|
||||
type: TransactionType
|
||||
description: str
|
||||
timestamp: datetime
|
||||
```
|
||||
|
||||
### Admin Interface
|
||||
|
||||
#### `routstr/core/admin.py`
|
||||
|
||||
Web dashboard and admin API:
|
||||
|
||||
```python
|
||||
@admin_router.get("/admin/")
|
||||
async def admin_dashboard(request: Request):
|
||||
"""Render admin HTML interface"""
|
||||
# Authentication check
|
||||
# Load statistics
|
||||
# Render template
|
||||
|
||||
@admin_router.post("/admin/api/withdraw")
|
||||
async def withdraw_balance(
|
||||
api_key: str,
|
||||
amount: int | None = None
|
||||
) -> WithdrawalResponse:
|
||||
"""Generate eCash token for withdrawal"""
|
||||
```
|
||||
|
||||
Features:
|
||||
|
||||
- HTML dashboard
|
||||
- API key management
|
||||
- Balance withdrawals
|
||||
- Usage statistics
|
||||
|
||||
### Wallet Integration
|
||||
|
||||
#### `routstr/wallet.py`
|
||||
|
||||
Cashu wallet operations:
|
||||
|
||||
```python
|
||||
class WalletManager:
|
||||
"""Manage Cashu wallet instances"""
|
||||
|
||||
async def redeem_token(
|
||||
self,
|
||||
token: str,
|
||||
mint_url: str | None = None
|
||||
) -> int:
|
||||
"""Redeem eCash token and return value"""
|
||||
|
||||
async def create_token(
|
||||
self,
|
||||
amount: int,
|
||||
mint_url: str
|
||||
) -> str:
|
||||
"""Create eCash token for withdrawal"""
|
||||
```
|
||||
|
||||
### Utility Modules
|
||||
|
||||
#### `routstr/core/logging.py`
|
||||
|
||||
Structured logging configuration:
|
||||
|
||||
```python
|
||||
def setup_logging():
|
||||
"""Configure JSON structured logging"""
|
||||
# Set log level
|
||||
# Configure formatters
|
||||
# Add handlers
|
||||
|
||||
class RequestIdMiddleware:
|
||||
"""Add request ID to all logs"""
|
||||
```
|
||||
|
||||
#### `routstr/core/middleware.py`
|
||||
|
||||
HTTP middleware components:
|
||||
|
||||
```python
|
||||
class LoggingMiddleware:
|
||||
"""Log all HTTP requests/responses"""
|
||||
|
||||
class ErrorHandlingMiddleware:
|
||||
"""Consistent error responses"""
|
||||
```
|
||||
|
||||
#### `routstr/core/exceptions.py`
|
||||
|
||||
Custom exception hierarchy:
|
||||
|
||||
```python
|
||||
class RoustrError(Exception):
|
||||
"""Base exception with error details"""
|
||||
status_code: int
|
||||
error_type: str
|
||||
detail: str
|
||||
|
||||
class PaymentError(RoustrError):
|
||||
"""Payment-related errors"""
|
||||
|
||||
class UpstreamError(RoustrError):
|
||||
"""Upstream API errors"""
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### `pyproject.toml`
|
||||
|
||||
Project metadata and dependencies:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "routstr"
|
||||
version = "0.1.1b"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.115",
|
||||
"sqlmodel>=0.0.24",
|
||||
"cashu",
|
||||
# ...
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
```
|
||||
|
||||
### `alembic.ini`
|
||||
|
||||
Database migration configuration:
|
||||
|
||||
```ini
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
```
|
||||
|
||||
### `Makefile`
|
||||
|
||||
Development commands:
|
||||
|
||||
```makefile
|
||||
# Setup commands
|
||||
setup:
|
||||
uv sync
|
||||
uv pip install -e .
|
||||
|
||||
# Development server
|
||||
dev:
|
||||
fastapi dev routstr --host 0.0.0.0
|
||||
|
||||
# Testing
|
||||
test:
|
||||
uv run pytest
|
||||
|
||||
# Code quality
|
||||
lint:
|
||||
uv run ruff check .
|
||||
```
|
||||
|
||||
## Code Patterns
|
||||
|
||||
### Dependency Injection
|
||||
|
||||
Using FastAPI's DI system:
|
||||
|
||||
```python
|
||||
# Define dependency
|
||||
async def get_db() -> AsyncSession:
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
# Use in routes
|
||||
@router.get("/items")
|
||||
async def get_items(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Item))
|
||||
return result.scalars().all()
|
||||
```
|
||||
|
||||
### Async Context Managers
|
||||
|
||||
For resource management:
|
||||
|
||||
```python
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
|
||||
async with database.transaction():
|
||||
# Atomic operations
|
||||
```
|
||||
|
||||
### Type Safety
|
||||
|
||||
Leveraging Python 3.11+ features:
|
||||
|
||||
```python
|
||||
# Union types with |
|
||||
def process(value: str | int) -> dict[str, Any]:
|
||||
pass
|
||||
|
||||
# Type aliases
|
||||
Balance = int # millisatoshis
|
||||
TokenList = list[dict[str, str]]
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Consistent error responses:
|
||||
|
||||
```python
|
||||
try:
|
||||
result = await risky_operation()
|
||||
except SpecificError as e:
|
||||
logger.error("Operation failed", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "specific_error",
|
||||
"message": str(e)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Module Organization
|
||||
|
||||
1. **Single Responsibility**: Each module has one clear purpose
|
||||
2. **Minimal Imports**: Import only what's needed
|
||||
3. **Circular Dependencies**: Avoid by using dependency injection
|
||||
4. **Public API**: Expose through `__init__.py`
|
||||
|
||||
### Function Design
|
||||
|
||||
1. **Type Hints**: Always include complete type annotations
|
||||
2. **Async First**: Use async/await for I/O operations
|
||||
3. **Error Handling**: Raise specific exceptions
|
||||
4. **Documentation**: Docstrings for public functions
|
||||
|
||||
### Testing Structure
|
||||
|
||||
1. **Mirror Source**: Test structure matches source
|
||||
2. **Fixtures**: Reusable test data in conftest.py
|
||||
3. **Mocking**: Mock external dependencies
|
||||
4. **Coverage**: Aim for >80% coverage
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Review [Testing Guide](testing.md) for test structure
|
||||
- Read [Architecture](architecture.md) for system design
|
||||
@@ -0,0 +1,389 @@
|
||||
# Development Setup
|
||||
|
||||
This guide will help you set up a development environment for contributing to Routstr Core.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have:
|
||||
|
||||
- **Python 3.11+** - Required for type hints and modern features
|
||||
- **Git** - For version control
|
||||
- **Docker** (optional) - For running integration tests
|
||||
- **Make** - For running development commands
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Fork and Clone
|
||||
|
||||
First, fork the repository on GitHub, then clone your fork:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/routstr-core.git
|
||||
cd routstr-core
|
||||
```
|
||||
|
||||
### 2. Install uv
|
||||
|
||||
We use [uv](https://github.com/astral-sh/uv) for fast, reliable Python package management:
|
||||
|
||||
```bash
|
||||
# Using the installer script
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Or with pip
|
||||
pip install uv
|
||||
|
||||
# Or with Homebrew (macOS)
|
||||
brew install uv
|
||||
```
|
||||
|
||||
### 3. Set Up Environment
|
||||
|
||||
Run the setup command:
|
||||
|
||||
```bash
|
||||
make setup
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
- ✅ Install uv if not present
|
||||
- ✅ Create a virtual environment
|
||||
- ✅ Install all dependencies
|
||||
- ✅ Install dev tools (mypy, ruff, pytest)
|
||||
- ✅ Install project in editable mode
|
||||
|
||||
### 4. Configure Environment
|
||||
|
||||
Create your environment file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` with your configuration:
|
||||
|
||||
```bash
|
||||
# Minimum required for development
|
||||
UPSTREAM_BASE_URL=https://api.openai.com/v1
|
||||
UPSTREAM_API_KEY=your-api-key # Optional for mock testing
|
||||
ADMIN_PASSWORD=development-password
|
||||
DATABASE_URL=sqlite+aiosqlite:///dev.db
|
||||
```
|
||||
|
||||
### 5. Verify Installation
|
||||
|
||||
Run these commands to verify your setup:
|
||||
|
||||
```bash
|
||||
# Check dependencies
|
||||
make check-deps
|
||||
|
||||
# Run unit tests
|
||||
make test-unit
|
||||
|
||||
# Start development server
|
||||
make dev
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Running the Server
|
||||
|
||||
For development with auto-reload:
|
||||
|
||||
```bash
|
||||
make dev
|
||||
# Server starts at http://localhost:8000
|
||||
# Auto-reloads on code changes
|
||||
```
|
||||
|
||||
For production-like environment:
|
||||
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
Before committing, always run:
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
make format
|
||||
|
||||
# Check linting
|
||||
make lint
|
||||
|
||||
# Type checking
|
||||
make type-check
|
||||
|
||||
# All checks at once
|
||||
make check
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Run different test suites:
|
||||
|
||||
```bash
|
||||
# Unit tests only (fast)
|
||||
make test-unit
|
||||
|
||||
# Integration tests with mocks
|
||||
make test-integration
|
||||
|
||||
# All tests
|
||||
make test
|
||||
|
||||
# With coverage report
|
||||
make test-coverage
|
||||
|
||||
# Run specific test
|
||||
uv run pytest tests/unit/test_auth.py::test_token_validation -v
|
||||
```
|
||||
|
||||
### Database Management
|
||||
|
||||
Work with database migrations:
|
||||
|
||||
```bash
|
||||
# Create new migration
|
||||
make db-migrate
|
||||
|
||||
# Apply migrations
|
||||
make db-upgrade
|
||||
|
||||
# Rollback one migration
|
||||
make db-downgrade
|
||||
|
||||
# View current revision
|
||||
make db-current
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
Understanding the codebase:
|
||||
|
||||
```
|
||||
routstr-core/
|
||||
├── routstr/ # Main package
|
||||
│ ├── __init__.py # Package initialization
|
||||
│ ├── auth.py # Authentication logic
|
||||
│ ├── balance.py # Balance management
|
||||
│ ├── discovery.py # Nostr discovery
|
||||
│ ├── proxy.py # Request proxying
|
||||
│ ├── wallet.py # Cashu wallet integration
|
||||
│ │
|
||||
│ ├── core/ # Core modules
|
||||
│ │ ├── admin.py # Admin dashboard
|
||||
│ │ ├── db.py # Database models
|
||||
│ │ ├── exceptions.py # Custom exceptions
|
||||
│ │ ├── logging.py # Logging setup
|
||||
│ │ ├── main.py # FastAPI app
|
||||
│ │ └── middleware.py # HTTP middleware
|
||||
│ │
|
||||
│ └── payment/ # Payment processing
|
||||
│ ├── cost_calculation.py # Cost logic
|
||||
│ ├── helpers.py # Utilities
|
||||
│ ├── lnurl.py # Lightning URLs
|
||||
│ ├── models.py # Model pricing
|
||||
│ ├── price.py # BTC pricing
|
||||
│ └── x_cashu.py # Cashu headers
|
||||
│
|
||||
├── tests/ # Test suite
|
||||
│ ├── unit/ # Unit tests
|
||||
│ └── integration/ # Integration tests
|
||||
│
|
||||
├── migrations/ # Database migrations
|
||||
├── scripts/ # Utility scripts
|
||||
├── docs/ # Documentation
|
||||
│
|
||||
├── Makefile # Dev commands
|
||||
├── pyproject.toml # Project config
|
||||
└── compose.yml # Docker setup
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New Endpoint
|
||||
|
||||
1. Create route in appropriate module
|
||||
2. Add request/response models
|
||||
3. Write unit tests
|
||||
4. Update API documentation
|
||||
5. Add integration tests
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
# In routstr/core/main.py or appropriate router
|
||||
@app.get("/v1/stats")
|
||||
async def get_stats(
|
||||
user: User = Depends(get_current_user)
|
||||
) -> StatsResponse:
|
||||
"""Get usage statistics for the current user."""
|
||||
# Implementation
|
||||
pass
|
||||
```
|
||||
|
||||
### Adding a Database Model
|
||||
|
||||
1. Define model in `routstr/core/db.py`
|
||||
2. Create migration: `make db-migrate`
|
||||
3. Review generated migration
|
||||
4. Apply: `make db-upgrade`
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
class Transaction(SQLModel, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
api_key_id: int = Field(foreign_key="apikey.id")
|
||||
amount: int # millisatoshis
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
description: str
|
||||
```
|
||||
|
||||
### Writing Tests
|
||||
|
||||
Follow the AAA pattern:
|
||||
|
||||
```python
|
||||
async def test_balance_deduction():
|
||||
# Arrange
|
||||
api_key = await create_test_api_key(balance=1000)
|
||||
|
||||
# Act
|
||||
result = await deduct_balance(api_key.key, amount=100)
|
||||
|
||||
# Assert
|
||||
assert result.success
|
||||
assert result.new_balance == 900
|
||||
assert result.deducted == 100
|
||||
```
|
||||
|
||||
## Development Tools
|
||||
|
||||
### Makefile Commands
|
||||
|
||||
Key commands for development:
|
||||
|
||||
```bash
|
||||
make help # Show all commands
|
||||
make setup # Initial setup
|
||||
make dev # Run dev server
|
||||
make test # Run all tests
|
||||
make lint # Check code style
|
||||
make format # Fix code style
|
||||
make type-check # Check types
|
||||
make clean # Clean temp files
|
||||
make docker-build # Build Docker image
|
||||
```
|
||||
|
||||
### IDE Setup
|
||||
|
||||
#### VS Code
|
||||
|
||||
Recommended extensions:
|
||||
|
||||
- Python
|
||||
- Pylance
|
||||
- Ruff
|
||||
- GitLens
|
||||
|
||||
Settings (`.vscode/settings.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"python.linting.enabled": true,
|
||||
"python.linting.ruffEnabled": true,
|
||||
"python.formatting.provider": "ruff",
|
||||
"python.analysis.typeCheckingMode": "strict",
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
```
|
||||
|
||||
#### PyCharm
|
||||
|
||||
1. Set Python interpreter to uv venv
|
||||
2. Enable type checking
|
||||
3. Configure Ruff as external tool
|
||||
4. Set up file watchers for formatting
|
||||
|
||||
### Debugging
|
||||
|
||||
#### Debug Server
|
||||
|
||||
```bash
|
||||
# Run with debug logging
|
||||
LOG_LEVEL=DEBUG make dev
|
||||
|
||||
# Or with debugger
|
||||
uv run python -m debugpy --listen 5678 --wait-for-client \
|
||||
-m uvicorn routstr:fastapi_app --reload
|
||||
```
|
||||
|
||||
#### Debug Tests
|
||||
|
||||
```bash
|
||||
# Run specific test with output
|
||||
uv run pytest tests/unit/test_auth.py -v -s
|
||||
|
||||
# With debugger
|
||||
uv run pytest tests/unit/test_auth.py --pdb
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Import Errors**
|
||||
|
||||
```bash
|
||||
# Ensure project is installed in editable mode
|
||||
uv sync
|
||||
uv pip install -e .
|
||||
```
|
||||
|
||||
**Database Errors**
|
||||
|
||||
```bash
|
||||
# Reset database
|
||||
rm dev.db
|
||||
make db-upgrade
|
||||
```
|
||||
|
||||
**Type Checking Fails**
|
||||
|
||||
```bash
|
||||
# Clear mypy cache
|
||||
make clean
|
||||
make type-check
|
||||
```
|
||||
|
||||
**Tests Fail Locally**
|
||||
|
||||
```bash
|
||||
# Ensure test dependencies are installed
|
||||
uv sync --dev
|
||||
|
||||
# Check for leftover test data
|
||||
rm -rf test_*.db
|
||||
```
|
||||
|
||||
### Getting Help
|
||||
|
||||
- Check existing [GitHub Issues](https://github.com/routstr/routstr-core/issues)
|
||||
- Ask in [GitHub Discussions](https://github.com/routstr/routstr-core/discussions)
|
||||
- Read the [Architecture Guide](architecture.md)
|
||||
|
||||
## Next Steps
|
||||
|
||||
Now that you're set up:
|
||||
|
||||
1. Read the [Architecture Overview](architecture.md)
|
||||
3. Check [open issues](https://github.com/routstr/routstr-core/issues)
|
||||
4. Start with a small contribution
|
||||
|
||||
Happy coding! 🚀
|
||||
@@ -0,0 +1,581 @@
|
||||
# Testing Guide
|
||||
|
||||
This guide covers testing practices, patterns, and tools used in Routstr Core development.
|
||||
|
||||
## Testing Philosophy
|
||||
|
||||
We follow these principles:
|
||||
|
||||
- **Test Behavior, Not Implementation** - Tests should survive refactoring
|
||||
- **Fast Feedback** - Unit tests run in milliseconds
|
||||
- **Reliable Tests** - No flaky tests allowed
|
||||
- **Clear Failures** - Tests should clearly indicate what broke
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── __init__.py
|
||||
├── conftest.py # Shared fixtures and configuration
|
||||
├── unit/ # Fast, isolated unit tests
|
||||
│ ├── test_auth.py
|
||||
│ ├── test_balance.py
|
||||
│ ├── test_cost_calculation.py
|
||||
│ ├── test_models.py
|
||||
│ └── test_wallet.py
|
||||
└── integration/ # Component integration tests
|
||||
├── test_api_endpoints.py
|
||||
├── test_payment_flow.py
|
||||
├── test_proxy_streaming.py
|
||||
└── test_real_mint.py
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Quick Test Commands
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
make test
|
||||
|
||||
# Run unit tests only (fast)
|
||||
make test-unit
|
||||
|
||||
# Run integration tests
|
||||
make test-integration
|
||||
|
||||
# Run with coverage
|
||||
make test-coverage
|
||||
|
||||
# Run specific test file
|
||||
uv run pytest tests/unit/test_auth.py -v
|
||||
|
||||
# Run specific test
|
||||
uv run pytest tests/unit/test_auth.py::test_create_api_key -v
|
||||
|
||||
# Run tests matching pattern
|
||||
uv run pytest -k "balance" -v
|
||||
```
|
||||
|
||||
### Test Markers
|
||||
|
||||
Use markers to categorize tests:
|
||||
|
||||
```python
|
||||
@pytest.mark.slow
|
||||
async def test_heavy_computation():
|
||||
pass
|
||||
|
||||
@pytest.mark.requires_docker
|
||||
async def test_real_services():
|
||||
pass
|
||||
|
||||
# Run without slow tests
|
||||
pytest -m "not slow"
|
||||
|
||||
# Run only integration tests
|
||||
pytest -m "integration"
|
||||
```
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Unit Test Example
|
||||
|
||||
```python
|
||||
# tests/unit/test_auth.py
|
||||
import pytest
|
||||
from routstr.auth import create_api_key, validate_api_key
|
||||
|
||||
class TestAPIKeyAuth:
|
||||
"""Test API key authentication functionality"""
|
||||
|
||||
async def test_create_api_key(self, test_db):
|
||||
"""Test creating a new API key"""
|
||||
# Arrange
|
||||
initial_balance = 10000
|
||||
|
||||
# Act
|
||||
api_key = await create_api_key(
|
||||
balance=initial_balance,
|
||||
name="Test Key"
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert api_key.key.startswith("sk-")
|
||||
assert len(api_key.key) == 32
|
||||
assert api_key.balance == initial_balance
|
||||
|
||||
async def test_validate_invalid_key(self, test_db):
|
||||
"""Test validation fails for invalid key"""
|
||||
# Act & Assert
|
||||
with pytest.raises(InvalidAPIKeyError):
|
||||
await validate_api_key("invalid_key")
|
||||
```
|
||||
|
||||
### Integration Test Example
|
||||
|
||||
```python
|
||||
# tests/integration/test_payment_flow.py
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
class TestPaymentFlow:
|
||||
"""Test end-to-end payment flows"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_redemption_flow(
|
||||
self,
|
||||
app_client: AsyncClient,
|
||||
mock_cashu_wallet
|
||||
):
|
||||
"""Test complete token redemption and API usage"""
|
||||
# Arrange
|
||||
token = create_test_token(amount=5000)
|
||||
mock_cashu_wallet.redeem.return_value = 5000
|
||||
|
||||
# Act - Create API key
|
||||
response = await app_client.post(
|
||||
"/v1/wallet/create",
|
||||
json={"cashu_token": token}
|
||||
)
|
||||
|
||||
# Assert - Key created
|
||||
assert response.status_code == 200
|
||||
api_key = response.json()["api_key"]
|
||||
assert response.json()["balance"] == 5000
|
||||
|
||||
# Act - Use API key
|
||||
response = await app_client.post(
|
||||
"/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
json={
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "Hi"}]
|
||||
}
|
||||
)
|
||||
|
||||
# Assert - Request successful
|
||||
assert response.status_code == 200
|
||||
assert "choices" in response.json()
|
||||
```
|
||||
|
||||
## Test Fixtures
|
||||
|
||||
### Common Fixtures
|
||||
|
||||
Located in `tests/conftest.py`:
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
async def test_db():
|
||||
"""Provide a clean test database"""
|
||||
# Create in-memory SQLite database
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
async_session = sessionmaker(engine, class_=AsyncSession)
|
||||
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.fixture
|
||||
async def app_client(test_db):
|
||||
"""Provide test client with test database"""
|
||||
app.dependency_overrides[get_db] = lambda: test_db
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cashu_wallet(mocker):
|
||||
"""Mock Cashu wallet for testing"""
|
||||
mock = mocker.patch("routstr.wallet.Wallet")
|
||||
mock.return_value.redeem.return_value = 1000
|
||||
return mock
|
||||
```
|
||||
|
||||
### Using Fixtures
|
||||
|
||||
```python
|
||||
async def test_with_fixtures(
|
||||
test_db, # Get test database
|
||||
app_client, # Get test HTTP client
|
||||
mock_cashu_wallet # Get mocked wallet
|
||||
):
|
||||
# Use fixtures in test
|
||||
pass
|
||||
```
|
||||
|
||||
## Mocking Strategies
|
||||
|
||||
### Mocking External Services
|
||||
|
||||
```python
|
||||
# Mock upstream API
|
||||
@pytest.fixture
|
||||
def mock_openai(mocker):
|
||||
mock_response = mocker.Mock()
|
||||
mock_response.json.return_value = {
|
||||
"choices": [{
|
||||
"message": {"content": "Hello!"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 20,
|
||||
"total_tokens": 30
|
||||
}
|
||||
}
|
||||
|
||||
mocker.patch(
|
||||
"httpx.AsyncClient.post",
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
# Mock Cashu mint
|
||||
@pytest.fixture
|
||||
def mock_mint(mocker):
|
||||
mint = mocker.patch("routstr.wallet.Mint")
|
||||
mint.return_value.check_proof_state.return_value = True
|
||||
return mint
|
||||
```
|
||||
|
||||
### Mocking Time
|
||||
|
||||
```python
|
||||
from freezegun import freeze_time
|
||||
|
||||
@freeze_time("2024-01-01 12:00:00")
|
||||
async def test_time_dependent():
|
||||
# Time is frozen during test
|
||||
key = await create_api_key(expires_in_days=30)
|
||||
assert key.expires_at == datetime(2024, 1, 31, 12, 0, 0)
|
||||
```
|
||||
|
||||
## Test Patterns
|
||||
|
||||
### Testing Async Code
|
||||
|
||||
```python
|
||||
# Always mark async tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_function():
|
||||
result = await async_operation()
|
||||
assert result == expected
|
||||
|
||||
# Test async context managers
|
||||
async def test_async_context():
|
||||
async with create_resource() as resource:
|
||||
assert resource.is_active
|
||||
```
|
||||
|
||||
### Testing Exceptions
|
||||
|
||||
```python
|
||||
# Test specific exception
|
||||
async def test_raises_specific_error():
|
||||
with pytest.raises(InsufficientBalanceError) as exc_info:
|
||||
await deduct_balance(api_key, amount=999999)
|
||||
|
||||
assert "Insufficient balance" in str(exc_info.value)
|
||||
assert exc_info.value.status_code == 402
|
||||
|
||||
# Test exception details
|
||||
async def test_exception_details():
|
||||
with pytest.raises(RoustrError) as exc_info:
|
||||
await risky_operation()
|
||||
|
||||
error = exc_info.value
|
||||
assert error.error_type == "validation_error"
|
||||
assert error.detail == "Invalid input"
|
||||
```
|
||||
|
||||
### Testing Streaming Responses
|
||||
|
||||
```python
|
||||
async def test_streaming_response():
|
||||
"""Test streaming chat completion"""
|
||||
chunks = []
|
||||
|
||||
async with app_client.stream(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
json={"model": "gpt-3.5-turbo", "stream": True}
|
||||
) as response:
|
||||
async for line in response.aiter_lines():
|
||||
if line.startswith("data: "):
|
||||
chunks.append(json.loads(line[6:]))
|
||||
|
||||
# Verify chunks
|
||||
assert len(chunks) > 0
|
||||
assert chunks[-1] == "[DONE]"
|
||||
```
|
||||
|
||||
### Database Testing
|
||||
|
||||
```python
|
||||
async def test_database_transaction(test_db):
|
||||
"""Test atomic transactions"""
|
||||
async with test_db.begin():
|
||||
# Create test data
|
||||
api_key = APIKey(key_hash="test", balance=1000)
|
||||
test_db.add(api_key)
|
||||
await test_db.flush()
|
||||
|
||||
# Test rollback
|
||||
try:
|
||||
async with test_db.begin_nested():
|
||||
api_key.balance = -100 # Invalid
|
||||
await test_db.flush()
|
||||
raise ValueError("Rollback")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Verify rollback worked
|
||||
await test_db.refresh(api_key)
|
||||
assert api_key.balance == 1000
|
||||
```
|
||||
|
||||
## Performance Testing
|
||||
|
||||
### Benchmark Tests
|
||||
|
||||
```python
|
||||
@pytest.mark.benchmark
|
||||
def test_performance(benchmark):
|
||||
"""Benchmark critical functions"""
|
||||
result = benchmark(expensive_function, arg1, arg2)
|
||||
assert result == expected
|
||||
|
||||
# Run benchmarks
|
||||
pytest --benchmark-only
|
||||
```
|
||||
|
||||
### Load Testing
|
||||
|
||||
```python
|
||||
# tests/integration/test_load.py
|
||||
async def test_concurrent_requests(app_client):
|
||||
"""Test handling multiple concurrent requests"""
|
||||
async def make_request(i):
|
||||
response = await app_client.get(f"/test/{i}")
|
||||
return response.status_code
|
||||
|
||||
# Make 100 concurrent requests
|
||||
tasks = [make_request(i) for i in range(100)]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# All should succeed
|
||||
assert all(status == 200 for status in results)
|
||||
```
|
||||
|
||||
## Test Data
|
||||
|
||||
### Factories
|
||||
|
||||
```python
|
||||
# tests/factories.py
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
def create_test_api_key(**kwargs):
|
||||
"""Factory for test API keys"""
|
||||
defaults = {
|
||||
"key": f"sk-test-{uuid4().hex[:8]}",
|
||||
"balance": 10000,
|
||||
"created_at": datetime.utcnow(),
|
||||
"expires_at": datetime.utcnow() + timedelta(days=30)
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return APIKey(**defaults)
|
||||
|
||||
def create_test_token(amount: int = 1000, mint: str = None):
|
||||
"""Factory for test Cashu tokens"""
|
||||
# Create valid test token structure
|
||||
return base64.encode(...)
|
||||
```
|
||||
|
||||
### Test Constants
|
||||
|
||||
```python
|
||||
# tests/constants.py
|
||||
TEST_MODELS = {
|
||||
"gpt-3.5-turbo": {
|
||||
"prompt": 0.0015,
|
||||
"completion": 0.002
|
||||
},
|
||||
"gpt-4": {
|
||||
"prompt": 0.03,
|
||||
"completion": 0.06
|
||||
}
|
||||
}
|
||||
|
||||
TEST_API_KEY = "sk-test-1234567890"
|
||||
TEST_MINT_URL = "https://testmint.example.com"
|
||||
```
|
||||
|
||||
## Coverage
|
||||
|
||||
### Running Coverage
|
||||
|
||||
```bash
|
||||
# Generate coverage report
|
||||
make test-coverage
|
||||
|
||||
# View HTML report
|
||||
open htmlcov/index.html
|
||||
|
||||
# Coverage with specific tests
|
||||
pytest --cov=routstr --cov-report=html tests/unit/
|
||||
```
|
||||
|
||||
### Coverage Configuration
|
||||
|
||||
In `pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[tool.coverage.run]
|
||||
source = ["routstr"]
|
||||
omit = ["tests/*", "*/migrations/*"]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError",
|
||||
"if TYPE_CHECKING:"
|
||||
]
|
||||
```
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
### Print Debugging
|
||||
|
||||
```python
|
||||
# Use -s flag to see print output
|
||||
pytest -s tests/unit/test_auth.py
|
||||
|
||||
# In test
|
||||
async def test_debug():
|
||||
print(f"Value: {value}") # Will show with -s
|
||||
assert value == expected
|
||||
```
|
||||
|
||||
### Interactive Debugging
|
||||
|
||||
```python
|
||||
# Drop into debugger on failure
|
||||
pytest --pdb
|
||||
|
||||
# Set breakpoint in test
|
||||
async def test_debug():
|
||||
import pdb; pdb.set_trace()
|
||||
# Execution stops here
|
||||
```
|
||||
|
||||
### Logging in Tests
|
||||
|
||||
```python
|
||||
# Enable debug logging in tests
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
# Or use caplog fixture
|
||||
async def test_logging(caplog):
|
||||
with caplog.at_level(logging.INFO):
|
||||
await function_that_logs()
|
||||
|
||||
assert "Expected message" in caplog.text
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
Tests run automatically on:
|
||||
|
||||
- Pull requests
|
||||
- Pushes to main
|
||||
- Nightly schedules
|
||||
|
||||
See `.github/workflows/test.yml` for configuration.
|
||||
|
||||
### Pre-commit Hooks
|
||||
|
||||
Install pre-commit hooks:
|
||||
|
||||
```bash
|
||||
pre-commit install
|
||||
|
||||
# Run manually
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Do's
|
||||
|
||||
1. ✅ Write tests first (TDD)
|
||||
2. ✅ Keep tests simple and focused
|
||||
3. ✅ Use descriptive test names
|
||||
4. ✅ Test edge cases
|
||||
5. ✅ Mock external dependencies
|
||||
6. ✅ Use fixtures for setup
|
||||
7. ✅ Assert specific values
|
||||
|
||||
### Don'ts
|
||||
|
||||
1. ❌ Don't test implementation details
|
||||
2. ❌ Don't use production services
|
||||
3. ❌ Don't rely on test order
|
||||
4. ❌ Don't ignore flaky tests
|
||||
5. ❌ Don't skip error cases
|
||||
6. ❌ Don't use hard-coded waits
|
||||
7. ❌ Don't commit commented tests
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Async Test Errors**
|
||||
|
||||
```python
|
||||
# Wrong
|
||||
def test_async(): # Missing async
|
||||
await function()
|
||||
|
||||
# Right
|
||||
async def test_async():
|
||||
await function()
|
||||
```
|
||||
|
||||
**Database State**
|
||||
|
||||
```python
|
||||
# Ensure clean state
|
||||
@pytest.fixture(autouse=True)
|
||||
async def cleanup(test_db):
|
||||
yield
|
||||
# Cleanup after each test
|
||||
await test_db.execute("DELETE FROM apikey")
|
||||
await test_db.commit()
|
||||
```
|
||||
|
||||
**Mock Not Working**
|
||||
|
||||
```python
|
||||
# Check import path
|
||||
mocker.patch("routstr.wallet.Wallet") # Full path
|
||||
# Not just "Wallet"
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- See [Architecture](architecture.md) for system design
|
||||
- Read [Setup Guide](setup.md) for environment setup
|
||||
@@ -0,0 +1,259 @@
|
||||
# Configuration
|
||||
|
||||
Routstr Core is configured through environment variables. This guide covers all available options.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Core Settings
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `UPSTREAM_BASE_URL` | Base URL of the OpenAI-compatible API to proxy | - | ✅ |
|
||||
| `UPSTREAM_API_KEY` | API key for the upstream service | - | ❌ |
|
||||
| `DATABASE_URL` | SQLite database connection string | `sqlite+aiosqlite:///keys.db` | ❌ |
|
||||
| `ADMIN_PASSWORD` | Password for admin dashboard access | - | ⚠️ |
|
||||
|
||||
### Node Information
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `NAME` | Public name of your Routstr node | `ARoutstrNode` | ❌ |
|
||||
| `DESCRIPTION` | Description of your node | `A Routstr Node` | ❌ |
|
||||
| `NPUB` | Nostr public key for node identity | - | ❌ |
|
||||
| `HTTP_URL` | Public HTTP URL of your node | - | ❌ |
|
||||
| `ONION_URL` | Tor hidden service URL | - | ❌ |
|
||||
|
||||
### Cashu Configuration
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `CASHU_MINTS` | Comma-separated list of trusted Cashu mint URLs | `https://mint.minibits.cash/Bitcoin` | ❌ |
|
||||
| `RECEIVE_LN_ADDRESS` | Lightning address for automatic payouts | - | ❌ |
|
||||
|
||||
### Pricing Configuration
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `MODEL_BASED_PRICING` | Enable model-specific pricing from models.json | `false` | ❌ |
|
||||
| `COST_PER_REQUEST` | Fixed cost per API request in sats | `1` | ❌ |
|
||||
| `COST_PER_1K_INPUT_TOKENS` | Cost per 1000 input tokens in sats | `0` | ❌ |
|
||||
| `COST_PER_1K_OUTPUT_TOKENS` | Cost per 1000 output tokens in sats | `0` | ❌ |
|
||||
| `EXCHANGE_FEE` | Exchange rate markup (1.005 = 0.5% fee) | `1.005` | ❌ |
|
||||
| `UPSTREAM_PROVIDER_FEE` | Provider fee markup (1.05 = 5% fee) | `1.05` | ❌ |
|
||||
|
||||
### Network Configuration
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `CORS_ORIGINS` | Comma-separated list of allowed CORS origins | `*` | ❌ |
|
||||
| `TOR_PROXY_URL` | SOCKS5 proxy URL for Tor connections | `socks5://127.0.0.1:9050` | ❌ |
|
||||
|
||||
### Logging Configuration
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `LOG_LEVEL` | Logging level (DEBUG, INFO, WARNING, ERROR) | `INFO` | ❌ |
|
||||
| `ENABLE_CONSOLE_LOGGING` | Enable console log output | `true` | ❌ |
|
||||
|
||||
### Model Management
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `MODELS_PATH` | Path to custom models.json file | `models.json` | ❌ |
|
||||
| `BASE_URL` | Base URL for fetching model info | `https://openrouter.ai/api/v1` | ❌ |
|
||||
| `SOURCE` | Filter models by source provider | - | ❌ |
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### Basic OpenAI Proxy
|
||||
|
||||
```bash
|
||||
# .env
|
||||
UPSTREAM_BASE_URL=https://api.openai.com/v1
|
||||
UPSTREAM_API_KEY=sk-...
|
||||
ADMIN_PASSWORD=my-secure-password
|
||||
```
|
||||
|
||||
### Custom AI Provider
|
||||
|
||||
```bash
|
||||
# .env
|
||||
UPSTREAM_BASE_URL=https://api.anthropic.com/v1
|
||||
UPSTREAM_API_KEY=your-anthropic-key
|
||||
MODEL_BASED_PRICING=true
|
||||
MODELS_PATH=/app/config/anthropic-models.json
|
||||
```
|
||||
|
||||
### High-Security Setup
|
||||
|
||||
```bash
|
||||
# .env
|
||||
UPSTREAM_BASE_URL=https://api.openai.com/v1
|
||||
UPSTREAM_API_KEY=sk-...
|
||||
ADMIN_PASSWORD=very-long-secure-password-here
|
||||
CORS_ORIGINS=https://myapp.com,https://app.myapp.com
|
||||
TOR_PROXY_URL=socks5://tor:9050
|
||||
LOG_LEVEL=WARNING
|
||||
```
|
||||
|
||||
### Public Node Configuration
|
||||
|
||||
```bash
|
||||
# .env
|
||||
NAME=Lightning AI Gateway
|
||||
DESCRIPTION=Fast and reliable AI API access with Bitcoin payments
|
||||
NPUB=npub1abcd...
|
||||
HTTP_URL=https://api.lightning-ai.com
|
||||
ONION_URL=http://lightningai.onion
|
||||
CASHU_MINTS=https://mint1.com,https://mint2.com
|
||||
```
|
||||
|
||||
## Pricing Models
|
||||
|
||||
### Fixed Pricing
|
||||
|
||||
Simple per-request pricing:
|
||||
|
||||
```bash
|
||||
MODEL_BASED_PRICING=false
|
||||
COST_PER_REQUEST=10 # 10 sats per request
|
||||
```
|
||||
|
||||
### Token-Based Pricing
|
||||
|
||||
Charge based on token usage:
|
||||
|
||||
```bash
|
||||
MODEL_BASED_PRICING=false
|
||||
COST_PER_REQUEST=1 # 1 sat base fee
|
||||
COST_PER_1K_INPUT_TOKENS=5 # 5 sats per 1k input
|
||||
COST_PER_1K_OUTPUT_TOKENS=15 # 15 sats per 1k output
|
||||
```
|
||||
|
||||
### Model-Based Pricing
|
||||
|
||||
Use dynamic pricing from models.json:
|
||||
|
||||
```bash
|
||||
MODEL_BASED_PRICING=true
|
||||
EXCHANGE_FEE=1.01 # 1% exchange fee
|
||||
UPSTREAM_PROVIDER_FEE=1.00 # No additional markup
|
||||
```
|
||||
|
||||
## Custom Models Configuration
|
||||
|
||||
Create a `models.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-4",
|
||||
"name": "GPT-4",
|
||||
"pricing": {
|
||||
"prompt": "0.00003",
|
||||
"completion": "0.00006",
|
||||
"request": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gpt-3.5-turbo",
|
||||
"name": "GPT-3.5 Turbo",
|
||||
"pricing": {
|
||||
"prompt": "0.0000015",
|
||||
"completion": "0.000002",
|
||||
"request": "0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Admin Password
|
||||
|
||||
Generate a strong password:
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
### API Keys
|
||||
|
||||
- Rotate upstream API keys regularly
|
||||
- Use read-only keys when possible
|
||||
- Monitor key usage
|
||||
|
||||
### Network Security
|
||||
|
||||
- Restrict CORS origins in production
|
||||
- Use HTTPS for public endpoints
|
||||
- Enable Tor for anonymity
|
||||
|
||||
### Database Security
|
||||
|
||||
- Regular backups
|
||||
- Encrypted storage volumes
|
||||
- Restricted file permissions
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Check Current Configuration
|
||||
|
||||
```bash
|
||||
# View all environment variables
|
||||
docker exec routstr env | sort
|
||||
|
||||
# Test configuration
|
||||
curl http://localhost:8000/v1/info
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Missing Upstream URL**
|
||||
```
|
||||
ERROR: UPSTREAM_BASE_URL not set
|
||||
Solution: Set UPSTREAM_BASE_URL in .env
|
||||
```
|
||||
|
||||
**Invalid Cashu Mint**
|
||||
```
|
||||
ERROR: Failed to connect to mint
|
||||
Solution: Verify CASHU_MINTS URLs are accessible
|
||||
```
|
||||
|
||||
**Database Errors**
|
||||
```
|
||||
ERROR: Database connection failed
|
||||
Solution: Check DATABASE_URL and file permissions
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Multiple Mints
|
||||
|
||||
Configure fallback mints:
|
||||
```bash
|
||||
CASHU_MINTS=https://primary.mint,https://backup1.mint,https://backup2.mint
|
||||
```
|
||||
|
||||
### Custom Database
|
||||
|
||||
Use PostgreSQL instead of SQLite:
|
||||
```bash
|
||||
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/routstr
|
||||
```
|
||||
|
||||
### Proxy Settings
|
||||
|
||||
For corporate environments:
|
||||
```bash
|
||||
HTTP_PROXY=http://proxy.company.com:8080
|
||||
HTTPS_PROXY=http://proxy.company.com:8080
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [User Guide](../user-guide/introduction.md) - Start using Routstr
|
||||
- [Admin Dashboard](../user-guide/admin-dashboard.md) - Manage your node
|
||||
- [Custom Pricing](../advanced/custom-pricing.md) - Advanced pricing strategies
|
||||
@@ -0,0 +1,337 @@
|
||||
# Docker Setup
|
||||
|
||||
This guide covers deploying Routstr Core using Docker for production environments.
|
||||
|
||||
## Docker Images
|
||||
|
||||
Official images are available on GitHub Container Registry:
|
||||
|
||||
```bash
|
||||
ghcr.io/routstr/proxy:latest
|
||||
```
|
||||
|
||||
## Basic Docker Run
|
||||
|
||||
### Minimal Setup
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name routstr \
|
||||
-p 8000:8000 \
|
||||
-e UPSTREAM_BASE_URL=https://api.openai.com/v1 \
|
||||
-e UPSTREAM_API_KEY=sk-... \
|
||||
-e ADMIN_PASSWORD=secure-password \
|
||||
ghcr.io/routstr/proxy:latest
|
||||
```
|
||||
|
||||
### With Persistent Storage
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name routstr \
|
||||
-p 8000:8000 \
|
||||
-v routstr-data:/app/data \
|
||||
-v routstr-logs:/app/logs \
|
||||
-e UPSTREAM_BASE_URL=https://api.openai.com/v1 \
|
||||
-e UPSTREAM_API_KEY=sk-... \
|
||||
-e DATABASE_URL=sqlite+aiosqlite:///data/keys.db \
|
||||
ghcr.io/routstr/proxy:latest
|
||||
```
|
||||
|
||||
## Docker Compose Setup
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
Create `compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
routstr:
|
||||
image: ghcr.io/routstr/proxy:latest
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### With Tor Hidden Service
|
||||
|
||||
The included `compose.yml` provides Tor support:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
routstr:
|
||||
build: . # Or use image: ghcr.io/routstr/proxy:latest
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./logs:/app/logs
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TOR_PROXY_URL=socks5://tor:9050
|
||||
ports:
|
||||
- 8000:8000
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
tor:
|
||||
image: ghcr.io/hundehausen/tor-hidden-service:latest
|
||||
volumes:
|
||||
- tor-data:/var/lib/tor
|
||||
environment:
|
||||
- HS_ROUTER=routstr:8000:80
|
||||
depends_on:
|
||||
- routstr
|
||||
|
||||
volumes:
|
||||
tor-data:
|
||||
```
|
||||
|
||||
### Environment File
|
||||
|
||||
Create `.env` file:
|
||||
|
||||
```bash
|
||||
# Required
|
||||
UPSTREAM_BASE_URL=https://api.openai.com/v1
|
||||
UPSTREAM_API_KEY=your-api-key
|
||||
ADMIN_PASSWORD=secure-admin-password
|
||||
|
||||
# Cashu Configuration
|
||||
CASHU_MINTS=https://mint.minibits.cash/Bitcoin
|
||||
|
||||
# Optional
|
||||
NAME=My Routstr Node
|
||||
DESCRIPTION=Pay-per-use AI API proxy
|
||||
NPUB=npub1...
|
||||
HTTP_URL=https://api.mynode.com
|
||||
ONION_URL=http://mynode.onion
|
||||
|
||||
# Pricing
|
||||
MODEL_BASED_PRICING=true
|
||||
EXCHANGE_FEE=1.005
|
||||
UPSTREAM_PROVIDER_FEE=1.05
|
||||
```
|
||||
|
||||
## Building Custom Image
|
||||
|
||||
### Dockerfile Overview
|
||||
|
||||
The provided Dockerfile:
|
||||
|
||||
- Uses Alpine Linux for small size
|
||||
- Installs required dependencies for secp256k1
|
||||
- Runs as non-root user
|
||||
- Exposes port 8000
|
||||
|
||||
### Build Locally
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/routstr/routstr-core.git
|
||||
cd routstr-core
|
||||
|
||||
# Build image
|
||||
docker build -t my-routstr:latest .
|
||||
|
||||
# Run custom image
|
||||
docker run -d \
|
||||
--name routstr \
|
||||
-p 8000:8000 \
|
||||
--env-file .env \
|
||||
my-routstr:latest
|
||||
```
|
||||
|
||||
## Deployment Considerations
|
||||
|
||||
### Resource Requirements
|
||||
|
||||
- **CPU**: 1-2 cores recommended
|
||||
- **Memory**: 512MB-1GB
|
||||
- **Storage**: 1GB + database growth
|
||||
- **Network**: Low latency to upstream provider
|
||||
|
||||
### Health Checks
|
||||
|
||||
Add health check to compose.yml:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
routstr:
|
||||
# ... other config ...
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/v1/info"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
```
|
||||
|
||||
### Reverse Proxy Setup
|
||||
|
||||
#### Nginx Example
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name api.yournode.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# For streaming responses
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_set_header Connection '';
|
||||
proxy_http_version 1.1;
|
||||
chunked_transfer_encoding off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Caddy Example
|
||||
|
||||
```caddy
|
||||
api.yournode.com {
|
||||
reverse_proxy localhost:8000 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Log Management
|
||||
|
||||
View logs:
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker logs -f routstr
|
||||
|
||||
# Docker Compose
|
||||
docker compose logs -f routstr
|
||||
|
||||
# Log files
|
||||
tail -f ./logs/routstr.log
|
||||
```
|
||||
|
||||
### Metrics
|
||||
|
||||
Monitor key metrics:
|
||||
|
||||
- Request count and latency
|
||||
- Token validation success rate
|
||||
- Upstream API errors
|
||||
- Database size growth
|
||||
|
||||
## Backup and Recovery
|
||||
|
||||
### Database Backup
|
||||
|
||||
```bash
|
||||
# Backup SQLite database
|
||||
docker exec routstr sqlite3 /app/data/keys.db ".backup /app/data/backup.db"
|
||||
|
||||
# Copy backup locally
|
||||
docker cp routstr:/app/data/backup.db ./backup-$(date +%Y%m%d).db
|
||||
```
|
||||
|
||||
### Restore from Backup
|
||||
|
||||
```bash
|
||||
# Stop service
|
||||
docker compose down
|
||||
|
||||
# Restore database
|
||||
docker cp ./backup.db routstr:/app/data/keys.db
|
||||
|
||||
# Restart service
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- Never commit `.env` files
|
||||
- Use Docker secrets for sensitive data
|
||||
- Rotate API keys regularly
|
||||
- Use strong admin passwords
|
||||
|
||||
### Network Security
|
||||
|
||||
- Use HTTPS/TLS termination
|
||||
- Restrict admin interface access
|
||||
- Enable firewall rules
|
||||
- Monitor for suspicious activity
|
||||
|
||||
### Container Security
|
||||
|
||||
- Run as non-root user
|
||||
- Use read-only filesystem where possible
|
||||
- Limit container capabilities
|
||||
- Keep base image updated
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container Won't Start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker logs routstr
|
||||
|
||||
# Verify environment
|
||||
docker exec routstr env | grep -E "(UPSTREAM|CASHU|ADMIN)"
|
||||
|
||||
# Test database connection
|
||||
docker exec routstr sqlite3 /app/data/keys.db ".tables"
|
||||
```
|
||||
|
||||
### Permission Issues
|
||||
|
||||
```bash
|
||||
# Fix volume permissions
|
||||
sudo chown -R 1000:1000 ./data ./logs
|
||||
```
|
||||
|
||||
### Network Issues
|
||||
|
||||
```bash
|
||||
# Test upstream connectivity
|
||||
docker exec routstr curl -I https://api.openai.com
|
||||
|
||||
# Check DNS resolution
|
||||
docker exec routstr nslookup api.openai.com
|
||||
```
|
||||
|
||||
## Production Checklist
|
||||
|
||||
- [ ] Set strong `ADMIN_PASSWORD`
|
||||
- [ ] Configure proper `UPSTREAM_BASE_URL` and `UPSTREAM_API_KEY`
|
||||
- [ ] Set up persistent volumes for data and logs
|
||||
- [ ] Configure reverse proxy with TLS
|
||||
- [ ] Set up monitoring and alerting
|
||||
- [ ] Implement backup strategy
|
||||
- [ ] Test disaster recovery
|
||||
- [ ] Document deployment process
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configuration Guide](configuration.md) - All environment variables
|
||||
- [Admin Dashboard](../user-guide/admin-dashboard.md) - Manage your node
|
||||
@@ -0,0 +1,156 @@
|
||||
# Overview
|
||||
|
||||
Routstr Core is a powerful payment proxy that brings Bitcoin micropayments to AI APIs. This overview will help you understand the core concepts and architecture.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Payment Proxy
|
||||
|
||||
Routstr acts as a transparent proxy between your application and OpenAI-compatible APIs. It:
|
||||
|
||||
- Intercepts API requests
|
||||
- Validates payment tokens
|
||||
- Forwards requests to the upstream provider
|
||||
- Tracks usage and deducts costs
|
||||
- Returns responses to the client
|
||||
|
||||
### Cashu eCash Protocol
|
||||
|
||||
[Cashu](https://cashu.space) is a Bitcoin eCash protocol that enables:
|
||||
|
||||
- **Privacy**: Payments are unlinkable and untraceable
|
||||
- **Instant Settlement**: No waiting for blockchain confirmations
|
||||
- **Micropayments**: Send fractions of a satoshi
|
||||
- **Offline Capability**: Tokens can be transferred without internet
|
||||
|
||||
### Lightning Network Integration
|
||||
|
||||
Routstr connects to the Lightning Network through Cashu mints, enabling:
|
||||
|
||||
- Fast Bitcoin deposits and withdrawals
|
||||
- Global payment reach
|
||||
- Low transaction fees
|
||||
- No minimum payment amounts
|
||||
|
||||
## Architecture
|
||||
|
||||
### System Components
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Client Side"
|
||||
A[AI Application]
|
||||
B[OpenAI SDK]
|
||||
C[eCash Wallet]
|
||||
end
|
||||
|
||||
subgraph "Routstr Core"
|
||||
D[FastAPI Server]
|
||||
E[Auth Module]
|
||||
F[Payment Module]
|
||||
G[Proxy Module]
|
||||
H[SQLite Database]
|
||||
end
|
||||
|
||||
subgraph "External Services"
|
||||
I[Upstream AI Provider]
|
||||
J[Cashu Mint]
|
||||
K[Bitcoin/Lightning]
|
||||
end
|
||||
|
||||
A --> B
|
||||
B --> D
|
||||
C --> D
|
||||
D --> E
|
||||
E --> F
|
||||
F --> J
|
||||
D --> G
|
||||
G --> I
|
||||
D --> H
|
||||
J --> K
|
||||
```
|
||||
|
||||
### Key Modules
|
||||
|
||||
1. **Authentication** (`auth.py`)
|
||||
- API key validation
|
||||
- Balance checking
|
||||
- Request authorization
|
||||
|
||||
2. **Payment Processing** (`payment/`)
|
||||
- Token validation
|
||||
- Cost calculation
|
||||
- Balance updates
|
||||
- Pricing models
|
||||
|
||||
3. **Proxy Handler** (`proxy.py`)
|
||||
- Request forwarding
|
||||
- Response streaming
|
||||
- Usage tracking
|
||||
- Error handling
|
||||
|
||||
4. **Wallet Management** (`wallet.py`)
|
||||
- Cashu wallet integration
|
||||
- Token redemption
|
||||
- Balance management
|
||||
- Automatic payouts
|
||||
|
||||
5. **Admin Interface** (`core/admin.py`)
|
||||
- Web dashboard
|
||||
- Balance viewing
|
||||
- Key management
|
||||
- Withdrawal interface
|
||||
|
||||
## Payment Flow
|
||||
|
||||
### Standard Flow (API Key)
|
||||
|
||||
1. User deposits eCash tokens to create an API key
|
||||
2. Client sends requests with the API key
|
||||
3. Routstr checks balance and forwards request
|
||||
4. Cost is deducted based on actual usage
|
||||
5. Response is returned to client
|
||||
|
||||
### Per-Request Flow (Coming Soon)
|
||||
|
||||
1. Client includes eCash token in request header
|
||||
2. Routstr validates token meets minimum amount
|
||||
3. Request is processed
|
||||
4. Change is returned in response header
|
||||
5. No account or balance needed
|
||||
|
||||
## Supported Features
|
||||
|
||||
### API Compatibility
|
||||
|
||||
- ✅ Chat completions (streaming and non-streaming)
|
||||
- ✅ Text completions
|
||||
- ✅ Embeddings
|
||||
- ✅ Image generation
|
||||
- ✅ Audio transcription/translation
|
||||
- ✅ Model listing
|
||||
- ✅ Custom endpoints
|
||||
|
||||
### Payment Features
|
||||
|
||||
- ✅ Multiple Cashu mint support
|
||||
- ✅ Automatic balance tracking
|
||||
- ✅ Model-based pricing
|
||||
- ✅ USD to BTC conversion
|
||||
- ✅ Configurable fees
|
||||
- ✅ Balance withdrawals
|
||||
|
||||
### Operational Features
|
||||
|
||||
- ✅ Docker deployment
|
||||
- ✅ Tor hidden service support
|
||||
- ✅ Nostr relay discovery
|
||||
- ✅ Database migrations
|
||||
- ✅ Comprehensive logging
|
||||
- ✅ Admin dashboard
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Quick Start](quickstart.md) - Get running in minutes
|
||||
- [Docker Setup](docker.md) - Deploy with containers
|
||||
- [Configuration](configuration.md) - Customize your instance
|
||||
@@ -0,0 +1,214 @@
|
||||
# Quick Start
|
||||
|
||||
Get Routstr Core up and running in minutes with Docker or local development setup.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose (for production)
|
||||
- Python 3.11+ (for development)
|
||||
- A Cashu-compatible wallet (optional for testing)
|
||||
|
||||
## Option 1: Docker (Recommended)
|
||||
|
||||
### Quick Run
|
||||
|
||||
The fastest way to start Routstr Core:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name routstr-proxy \
|
||||
-p 8000:8000 \
|
||||
-e UPSTREAM_BASE_URL=https://api.openai.com/v1 \
|
||||
-e UPSTREAM_API_KEY=your-openai-api-key \
|
||||
ghcr.io/routstr/proxy:latest
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
For a full setup with Tor support:
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/routstr/routstr-core.git
|
||||
cd routstr-core
|
||||
```
|
||||
|
||||
2. Create environment file:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
```
|
||||
|
||||
3. Start the services:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This will start:
|
||||
- Routstr proxy on port 8000
|
||||
- Tor hidden service (optional)
|
||||
- Automatic database migrations
|
||||
|
||||
### Verify Installation
|
||||
|
||||
Check that Routstr is running:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/v1/info
|
||||
```
|
||||
|
||||
You should see:
|
||||
```json
|
||||
{
|
||||
"name": "ARoutstrNode",
|
||||
"description": "A Routstr Node",
|
||||
"version": "0.1.1b",
|
||||
"npub": "",
|
||||
"mints": ["https://mint.minibits.cash/Bitcoin"],
|
||||
"models": {...}
|
||||
}
|
||||
```
|
||||
|
||||
## Option 2: Local Development
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
1. Install [uv](https://github.com/astral-sh/uv) package manager:
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
2. Clone and setup:
|
||||
```bash
|
||||
git clone https://github.com/routstr/routstr-core.git
|
||||
cd routstr-core
|
||||
uv sync
|
||||
```
|
||||
|
||||
3. Configure environment:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
```
|
||||
|
||||
### Run the Server
|
||||
|
||||
```bash
|
||||
fastapi run routstr --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## First API Call
|
||||
|
||||
### 1. Get an eCash Token
|
||||
|
||||
You'll need a Cashu token to pay for API calls. Options:
|
||||
|
||||
- Use a [Cashu wallet](https://cashu.space) to create tokens
|
||||
- Get test tokens from a testnet mint
|
||||
- Use the example token (for testing only)
|
||||
|
||||
### 2. Create an API Key
|
||||
|
||||
Send your eCash token to create an API key:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/v1/wallet/create \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"cashu_token": "cashuAeyJ0b2..."
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"api_key": "rUvK7...",
|
||||
"balance": 10000
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Make an API Call
|
||||
|
||||
Use your API key like a normal OpenAI key:
|
||||
|
||||
```python
|
||||
import openai
|
||||
|
||||
client = openai.OpenAI(
|
||||
api_key="rUvK7...", # Your Routstr API key
|
||||
base_url="http://localhost:8000/v1"
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": "Hello!"}]
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)
|
||||
```
|
||||
|
||||
## Example Client
|
||||
|
||||
Run the included example:
|
||||
|
||||
```bash
|
||||
CASHU_TOKEN="your-token" python example.py
|
||||
```
|
||||
|
||||
This demonstrates:
|
||||
- Creating an API key from a token
|
||||
- Making streaming chat requests
|
||||
- Automatic balance deduction
|
||||
|
||||
## Testing the Setup
|
||||
|
||||
### Check Available Models
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/v1/models
|
||||
```
|
||||
|
||||
### View Admin Dashboard
|
||||
|
||||
Open http://localhost:8000/admin/ in your browser.
|
||||
|
||||
Default password is set in `ADMIN_PASSWORD` environment variable.
|
||||
|
||||
### Monitor Logs
|
||||
|
||||
Docker:
|
||||
```bash
|
||||
docker compose logs -f routstr
|
||||
```
|
||||
|
||||
Local:
|
||||
```bash
|
||||
# Logs are in ./logs/ directory
|
||||
tail -f logs/routstr.log
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Connection Refused
|
||||
|
||||
- Ensure the service is running: `docker ps`
|
||||
- Check firewall settings
|
||||
- Verify port 8000 is not in use
|
||||
|
||||
### Invalid API Key
|
||||
|
||||
- Ensure you've created an API key with sufficient balance
|
||||
- Check the token was valid and had value
|
||||
- Verify the mint URL is accessible
|
||||
|
||||
### Upstream Errors
|
||||
|
||||
- Check `UPSTREAM_BASE_URL` is correct
|
||||
- Verify `UPSTREAM_API_KEY` if required
|
||||
- Test upstream service directly
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configuration Guide](configuration.md) - Customize settings
|
||||
- [Docker Setup](docker.md) - Production deployment
|
||||
- [User Guide](../user-guide/introduction.md) - Detailed usage
|
||||
@@ -0,0 +1,83 @@
|
||||
# Routstr Core Documentation
|
||||
|
||||
Welcome to the official documentation for **Routstr Core** - a FastAPI-based reverse proxy that enables Bitcoin micropayments for OpenAI-compatible APIs using the Cashu eCash protocol.
|
||||
|
||||
## What is Routstr Core?
|
||||
|
||||
Routstr Core is a payment proxy that sits between API clients and OpenAI-compatible services. It enables:
|
||||
|
||||
- **Pay-per-request billing** using Bitcoin eCash tokens
|
||||
- **Seamless integration** with existing OpenAI clients
|
||||
- **Privacy-preserving payments** through the Cashu protocol
|
||||
- **Flexible pricing models** with per-token or per-request billing
|
||||
- **Multi-provider support** for various AI model providers
|
||||
|
||||
### Key Features
|
||||
|
||||
- 🪙 **Cashu Wallet Integration** - Accept Lightning payments and redeem eCash tokens
|
||||
- 🔑 **API Key Management** - Secure key storage with balance tracking
|
||||
- 💰 **Dynamic Pricing** - Model-based pricing with live BTC/USD conversion
|
||||
- 🎛️ **Admin Dashboard** - Web interface for balance and key management
|
||||
- 🌐 **Nostr Discovery** - Find providers through decentralized relay network
|
||||
- 🐋 **Docker Support** - Easy deployment with optional Tor hidden service
|
||||
- ⚡ **Lightning Fast** - Minimal latency overhead for API requests
|
||||
|
||||
## How It Works
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Routstr as Routstr Proxy
|
||||
participant DB as Database
|
||||
participant Upstream as AI Provider
|
||||
participant Wallet as Cashu Wallet
|
||||
|
||||
Client->>Routstr: API Request + eCash Token
|
||||
Routstr->>Wallet: Validate & Redeem Token
|
||||
Wallet-->>Routstr: Token Value (sats)
|
||||
Routstr->>DB: Store/Update Balance
|
||||
Routstr->>Upstream: Forward API Request
|
||||
Upstream-->>Routstr: API Response + Usage Data
|
||||
Routstr->>DB: Deduct Actual Cost
|
||||
Routstr-->>Client: API Response
|
||||
```
|
||||
|
||||
## Quick Links
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :rocket: **[Quick Start](getting-started/quickstart.md)**
|
||||
|
||||
Get up and running with Docker in minutes
|
||||
|
||||
- :gear: **[Configuration](getting-started/configuration.md)**
|
||||
|
||||
Learn about environment variables and settings
|
||||
|
||||
- :book: **[User Guide](user-guide/introduction.md)**
|
||||
|
||||
Comprehensive guide for using Routstr
|
||||
|
||||
- :hammer: **[Contributing](contributing/setup.md)**
|
||||
|
||||
Help improve Routstr Core
|
||||
|
||||
</div>
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **AI Application Developers** - Add Bitcoin payments to your AI apps without managing infrastructure
|
||||
- **API Resellers** - Resell API access with custom pricing and profit margins
|
||||
- **Privacy-Focused Users** - Access AI models without revealing personal information
|
||||
- **Micropayment Experiments** - Test new business models with instant, small payments
|
||||
|
||||
## Getting Help
|
||||
|
||||
- 📖 Browse the [User Guide](user-guide/introduction.md) for detailed usage instructions
|
||||
- 🐛 Report issues on [GitHub](https://github.com/routstr/routstr-core/issues)
|
||||
- 💬 Join the community discussions
|
||||
- 🔧 Check the [API Reference](api/overview.md) for technical details
|
||||
|
||||
## License
|
||||
|
||||
Routstr Core is open source software licensed under the GPLv3. See the [LICENSE](https://github.com/routstr/routstr-core/blob/main/LICENSE) file for details.
|
||||
@@ -0,0 +1,4 @@
|
||||
mkdocs>=1.5.3
|
||||
mkdocs-material>=9.5.0
|
||||
mkdocs-mermaid2-plugin>=1.1.1
|
||||
pymdown-extensions>=10.5
|
||||
@@ -0,0 +1,329 @@
|
||||
# Admin Dashboard
|
||||
|
||||
The Routstr admin dashboard provides a web interface for managing your node, viewing balances, and handling withdrawals.
|
||||
|
||||
## 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.
|
||||
|
||||
1. Navigate to `/admin/`
|
||||
2. Enter the admin password
|
||||
3. Click "Login"
|
||||
|
||||
The password is stored as a secure cookie for the session.
|
||||
|
||||
## Dashboard Overview
|
||||
|
||||
### Main Interface
|
||||
|
||||
The dashboard displays:
|
||||
|
||||
- **Node Information**
|
||||
- Node name and description
|
||||
- Version number
|
||||
- Public URLs (HTTP and Onion)
|
||||
- Supported Cashu mints
|
||||
|
||||
- **Statistics**
|
||||
- Total API keys
|
||||
- Active keys
|
||||
- Total balance across all keys
|
||||
- Recent activity
|
||||
|
||||
- **API Key List**
|
||||
- All keys with balances
|
||||
- Usage statistics
|
||||
- Management options
|
||||
|
||||
## Features
|
||||
|
||||
### Viewing API Keys
|
||||
|
||||
The main table shows all API keys with:
|
||||
|
||||
| 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 |
|
||||
|
||||
### Searching and Filtering
|
||||
|
||||
- **Search**: Find keys by partial match
|
||||
- **Sort**: Click column headers to sort
|
||||
- **Filter**: Show only active/expired keys
|
||||
- **Export**: Download data as CSV
|
||||
|
||||
### Key Details
|
||||
|
||||
Click on any key to view:
|
||||
|
||||
- Full API key (masked by default)
|
||||
- Complete transaction history
|
||||
- Usage graphs
|
||||
- Metadata (name, expiry, refund address)
|
||||
|
||||
## Balance Management
|
||||
|
||||
### Viewing Balances
|
||||
|
||||
Balances are displayed in multiple units:
|
||||
|
||||
- **Sats**: Standard satoshi units
|
||||
- **mSats**: Millisatoshis (internal precision)
|
||||
- **BTC**: Bitcoin decimal format
|
||||
- **USD**: Approximate USD value
|
||||
|
||||
### Balance History
|
||||
|
||||
View balance changes over time:
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
## Withdrawals
|
||||
|
||||
### Manual Withdrawal
|
||||
|
||||
To withdraw funds from an API key:
|
||||
|
||||
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
|
||||
|
||||
### Bulk Operations
|
||||
|
||||
For multiple withdrawals:
|
||||
|
||||
1. Select keys using checkboxes
|
||||
2. Click "Bulk Actions" → "Withdraw"
|
||||
3. Tokens are generated for each key
|
||||
4. Download all tokens as text file
|
||||
|
||||
### Automatic Withdrawals
|
||||
|
||||
If configured with `RECEIVE_LN_ADDRESS`:
|
||||
|
||||
- Balances above threshold auto-convert to Lightning
|
||||
- Sent to configured Lightning address
|
||||
- View payout history in dashboard
|
||||
|
||||
## Node Configuration
|
||||
|
||||
### Viewing Settings
|
||||
|
||||
Current node configuration is displayed:
|
||||
|
||||
- Upstream provider URL
|
||||
- Enabled features
|
||||
- Pricing model
|
||||
- Fee structure
|
||||
|
||||
### Models and Pricing
|
||||
|
||||
View supported models and their pricing:
|
||||
|
||||
| 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 |
|
||||
|
||||
### Updating Configuration
|
||||
|
||||
> **Note**: Configuration changes require node restart.
|
||||
|
||||
To update settings:
|
||||
|
||||
1. Modify environment variables
|
||||
2. Restart the node
|
||||
3. Verify changes in dashboard
|
||||
|
||||
## Analytics
|
||||
|
||||
### Usage Statistics
|
||||
|
||||
View comprehensive usage data:
|
||||
|
||||
- **Requests per Day**: Line graph
|
||||
- **Token Usage**: Stacked bar chart
|
||||
- **Model Distribution**: Pie chart
|
||||
- **Cost Analysis**: Breakdown by model
|
||||
|
||||
### Performance Metrics
|
||||
|
||||
Monitor node performance:
|
||||
|
||||
- Average response time
|
||||
- Request success rate
|
||||
- Upstream API latency
|
||||
- Cache hit ratio
|
||||
|
||||
### Export Data
|
||||
|
||||
Export analytics data:
|
||||
|
||||
1. Select date range
|
||||
2. Choose metrics
|
||||
3. Click "Export"
|
||||
4. Download as CSV/JSON
|
||||
|
||||
## Security Features
|
||||
|
||||
### Access Control
|
||||
|
||||
- Password protection
|
||||
- Session timeout (configurable)
|
||||
- IP allowlisting (optional)
|
||||
- Audit logging
|
||||
|
||||
### Security Log
|
||||
|
||||
View security events:
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
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
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cannot Access Dashboard
|
||||
|
||||
**Issue**: 404 Not Found
|
||||
|
||||
- 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
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,245 @@
|
||||
# User Guide Introduction
|
||||
|
||||
Welcome to the Routstr Core User Guide. This guide will help you understand how to use Routstr to access AI APIs with Bitcoin micropayments.
|
||||
|
||||
## What You'll Learn
|
||||
|
||||
- How the payment system works
|
||||
- Creating and managing API keys
|
||||
- Making API calls through Routstr
|
||||
- Using the admin dashboard
|
||||
- Managing your balance
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before starting, you'll need:
|
||||
|
||||
1. **A Running Routstr Instance**
|
||||
- Either your own deployment or access to a public node
|
||||
- The base URL (e.g., `https://api.routstr.com/v1`)
|
||||
|
||||
2. **A Cashu Wallet** (optional but recommended)
|
||||
- [Nutstash](https://nutstash.app) - Web wallet
|
||||
- [Minibits](https://www.minibits.cash) - Mobile wallet
|
||||
- [Cashu.me](https://cashu.me) - Simple web wallet
|
||||
|
||||
3. **An API Client**
|
||||
- OpenAI Python/JavaScript SDK
|
||||
- Any HTTP client (curl, Postman, etc.)
|
||||
- Your application code
|
||||
|
||||
## How Routstr Works
|
||||
|
||||
### Traditional API Access
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Your App] --> B[OpenAI API]
|
||||
B --> A
|
||||
```
|
||||
|
||||
- Direct connection to provider
|
||||
- Monthly billing
|
||||
- Credit card required
|
||||
- Usage limits
|
||||
|
||||
### With Routstr
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Your App] --> B[Routstr Proxy]
|
||||
B --> C[OpenAI API]
|
||||
C --> B
|
||||
B --> A
|
||||
D[Bitcoin/eCash] --> B
|
||||
```
|
||||
|
||||
- Pay per request with Bitcoin
|
||||
- No credit card needed
|
||||
- Anonymous payments
|
||||
- Instant settlement
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### eCash Tokens
|
||||
|
||||
- Digital bearer tokens backed by Bitcoin
|
||||
- Can be sent like cash - whoever has the token owns it
|
||||
- Redeemable at Cashu mints for Bitcoin
|
||||
- Perfect for micropayments
|
||||
|
||||
### API Keys
|
||||
|
||||
- Created by depositing eCash tokens
|
||||
- Track your balance and usage
|
||||
- Can be topped up anytime
|
||||
- Optional expiry and refund address
|
||||
|
||||
### Balance Management
|
||||
|
||||
- Measured in millisatoshis (msats)
|
||||
- 1 Bitcoin = 100,000,000 sats = 100,000,000,000 msats
|
||||
- Deducted based on actual usage
|
||||
- Withdrawable as eCash tokens
|
||||
|
||||
## Typical Workflow
|
||||
|
||||
### 1. Get Bitcoin/eCash
|
||||
|
||||
Options:
|
||||
|
||||
- Buy Bitcoin and deposit to a Cashu mint
|
||||
- Receive eCash tokens from someone else
|
||||
- Use a testnet mint for testing
|
||||
|
||||
### 2. Use Your eCash
|
||||
|
||||
You have two options for using your eCash tokens with Routstr:
|
||||
|
||||
#### Option A: Create a Persistent Wallet
|
||||
|
||||
Create a wallet with an API key for multiple requests:
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/create
|
||||
{
|
||||
"cashu_token": "cashuAeyJ0..."
|
||||
}
|
||||
```
|
||||
|
||||
This returns an API key (`sk-...`) and your balance. The wallet persists between requests.
|
||||
|
||||
#### Option B: Direct Token Usage
|
||||
|
||||
Use your Cashu token directly as the API key:
|
||||
|
||||
```python
|
||||
client = OpenAI(
|
||||
api_key="cashuAeyJ0...", # Your Cashu token directly
|
||||
base_url="https://api.routstr.com/v1"
|
||||
)
|
||||
```
|
||||
|
||||
Routstr automatically converts the token to access the associated wallet. Each request consumes from the token's balance.
|
||||
|
||||
### 3. Make API Calls
|
||||
|
||||
With either method:
|
||||
|
||||
```python
|
||||
# Using persistent wallet API key
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="https://api.routstr.com/v1"
|
||||
)
|
||||
|
||||
# Or using Cashu token directly
|
||||
client = OpenAI(
|
||||
api_key="cashuAeyJ0...",
|
||||
base_url="https://api.routstr.com/v1"
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Monitor Usage
|
||||
|
||||
- Check balance: `GET /v1/wallet/balance`
|
||||
- View admin dashboard
|
||||
- Track costs per request
|
||||
|
||||
### 5. Withdraw Funds
|
||||
|
||||
When done, withdraw remaining balance as eCash through the admin interface.
|
||||
|
||||
## Supported Endpoints
|
||||
|
||||
Routstr supports all standard OpenAI endpoints:
|
||||
|
||||
- ✅ `/v1/chat/completions` - Chat models
|
||||
- 🚧 `/v1/completions` - Text completion (Coming soon)
|
||||
- 🚧 `/v1/embeddings` - Text embeddings (Coming soon)
|
||||
- 🚧 `/v1/images/generations` - Image generation (Coming soon)
|
||||
- 🚧 `/v1/audio/transcriptions` - Audio to text (Coming soon)
|
||||
- 🚧 `/v1/audio/translations` - Audio translation (Coming soon)
|
||||
- ✅ `/v1/models` - List available models
|
||||
- ✅ Custom provider endpoints
|
||||
|
||||
## Cost Structure
|
||||
|
||||
### Pricing Models
|
||||
|
||||
1. **Fixed Cost Per Request**
|
||||
- Simple flat fee per API call
|
||||
- Good for uniform usage
|
||||
|
||||
2. **Token-Based Pricing**
|
||||
- Pay per input/output token
|
||||
- More accurate for varied usage
|
||||
|
||||
3. **Model-Based Pricing**
|
||||
- Different rates per model
|
||||
- Reflects actual provider costs
|
||||
|
||||
### Cost Calculation
|
||||
|
||||
```
|
||||
Total Cost = Base Fee + (Input Tokens * Input Rate) + (Output Tokens * Output Rate)
|
||||
```
|
||||
|
||||
Fees may include:
|
||||
|
||||
- Exchange rate markup (BTC/USD conversion)
|
||||
- Provider margin
|
||||
- Node operator fee
|
||||
|
||||
## Getting Support
|
||||
|
||||
### Documentation
|
||||
|
||||
- This user guide for general usage
|
||||
- [API Reference](../api/overview.md) for technical details
|
||||
- [Contributing Guide](../contributing/setup.md) for developers
|
||||
|
||||
### Community
|
||||
|
||||
- GitHub Issues for bugs and features
|
||||
- Nostr for decentralized discussion
|
||||
- Node operator contact info
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Common issues and solutions:
|
||||
|
||||
- [Payment Flow](payment-flow.md) - Understanding the payment process
|
||||
- [Using the API](using-api.md) - API integration guide
|
||||
- [Admin Dashboard](admin-dashboard.md) - Managing your node
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### API Key Security
|
||||
|
||||
- Treat API keys like passwords
|
||||
- Never share or commit them
|
||||
- Rotate keys regularly
|
||||
- Use environment variables
|
||||
|
||||
### Payment Security
|
||||
|
||||
- eCash tokens are bearer instruments
|
||||
- Verify mint trustworthiness
|
||||
- Keep backups of tokens
|
||||
- Use small amounts for testing
|
||||
|
||||
### Network Security
|
||||
|
||||
- Always use HTTPS connections
|
||||
- Verify SSL certificates
|
||||
- Consider using Tor for privacy
|
||||
- Monitor for unusual activity
|
||||
|
||||
## Next Steps
|
||||
|
||||
Ready to start? Continue with:
|
||||
|
||||
1. [Payment Flow](payment-flow.md) - Detailed payment process
|
||||
2. [Using the API](using-api.md) - Making your first calls
|
||||
3. [Admin Dashboard](admin-dashboard.md) - Managing your account
|
||||
@@ -0,0 +1,466 @@
|
||||
# Models & Pricing
|
||||
|
||||
Understanding how Routstr calculates costs is essential for managing your API usage efficiently. This guide explains the pricing models and how to configure them.
|
||||
|
||||
## Pricing Models
|
||||
|
||||
Routstr supports three pricing models:
|
||||
|
||||
### 1. Fixed Pricing
|
||||
|
||||
Simple per-request charging:
|
||||
|
||||
```bash
|
||||
MODEL_BASED_PRICING=false
|
||||
COST_PER_REQUEST=10 # 10 sats per request
|
||||
```
|
||||
|
||||
**Best for:**
|
||||
|
||||
- Uniform API usage
|
||||
- Simple applications
|
||||
- Predictable costs
|
||||
|
||||
### 2. Token-Based Pricing
|
||||
|
||||
Charge based on actual token usage:
|
||||
|
||||
```bash
|
||||
MODEL_BASED_PRICING=false
|
||||
COST_PER_REQUEST=1 # 1 sat base fee
|
||||
COST_PER_1K_INPUT_TOKENS=5 # 5 sats per 1K input
|
||||
COST_PER_1K_OUTPUT_TOKENS=15 # 15 sats per 1K output
|
||||
```
|
||||
|
||||
**Best for:**
|
||||
|
||||
- Varied request sizes
|
||||
- Fair usage billing
|
||||
- Cost optimization
|
||||
|
||||
### 3. Model-Based Pricing
|
||||
|
||||
Dynamic pricing based on model costs:
|
||||
|
||||
```bash
|
||||
MODEL_BASED_PRICING=true
|
||||
EXCHANGE_FEE=1.005 # 0.5% exchange fee
|
||||
UPSTREAM_PROVIDER_FEE=1.05 # 5% provider fee
|
||||
```
|
||||
|
||||
**Best for:**
|
||||
|
||||
- Multiple models
|
||||
- Market-based pricing
|
||||
- Automatic updates
|
||||
|
||||
## Model Configuration
|
||||
|
||||
### Default Models
|
||||
|
||||
Routstr includes pricing for popular models:
|
||||
|
||||
| Model | Input ($/1K) | Output ($/1K) | Context | Notes |
|
||||
|-------|--------------|---------------|---------|-------|
|
||||
| gpt-3.5-turbo | $0.0015 | $0.002 | 16K | Fast, economical |
|
||||
| gpt-4 | $0.03 | $0.06 | 8K | Advanced reasoning |
|
||||
| gpt-4-turbo | $0.01 | $0.03 | 128K | Large context |
|
||||
| claude-3-opus | $0.015 | $0.075 | 200K | Best quality |
|
||||
| claude-3-sonnet | $0.003 | $0.015 | 200K | Balanced |
|
||||
| llama-2-70b | $0.0007 | $0.0009 | 4K | Open source |
|
||||
|
||||
### Custom Models File
|
||||
|
||||
Create `models.json` to override defaults:
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-4-vision",
|
||||
"name": "GPT-4 Vision",
|
||||
"pricing": {
|
||||
"prompt": "0.00003",
|
||||
"completion": "0.00006",
|
||||
"request": "0",
|
||||
"image": "0.00255"
|
||||
},
|
||||
"context_length": 128000,
|
||||
"supports_vision": true
|
||||
},
|
||||
{
|
||||
"id": "custom-model",
|
||||
"name": "My Custom Model",
|
||||
"pricing": {
|
||||
"prompt": "0.001",
|
||||
"completion": "0.002",
|
||||
"request": "0.0001"
|
||||
},
|
||||
"context_length": 8192
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Auto-updating Models
|
||||
|
||||
Fetch latest models from OpenRouter:
|
||||
|
||||
```bash
|
||||
# Update models from API
|
||||
python scripts/models_meta.py
|
||||
|
||||
# Or manually
|
||||
curl https://openrouter.ai/api/v1/models > models.json
|
||||
```
|
||||
|
||||
## Cost Calculation
|
||||
|
||||
### Understanding the Formula
|
||||
|
||||
```
|
||||
Base Cost = (Input Tokens × Input Rate) + (Output Tokens × Output Rate) + Request Fee
|
||||
|
||||
Bitcoin Price = Current BTC/USD rate (e.g., $50,000)
|
||||
Sats Cost = (Base Cost / Bitcoin Price) × 100,000,000
|
||||
|
||||
Final Cost = Sats Cost × Exchange Fee × Provider Fee
|
||||
```
|
||||
|
||||
### Example Calculations
|
||||
|
||||
**Example 1: Simple Chat (gpt-3.5-turbo)**
|
||||
|
||||
```
|
||||
Input: 50 tokens
|
||||
Output: 150 tokens
|
||||
Model rates: $0.0015/1K input, $0.002/1K output
|
||||
|
||||
USD Cost = (50/1000 × 0.0015) + (150/1000 × 0.002)
|
||||
= $0.000075 + $0.0003
|
||||
= $0.000375
|
||||
|
||||
At $50,000/BTC: 0.75 sats
|
||||
With 5.5% total fees: 0.79 sats
|
||||
```
|
||||
|
||||
**Example 2: Large Context (gpt-4)**
|
||||
|
||||
```
|
||||
Input: 2,000 tokens
|
||||
Output: 500 tokens
|
||||
Model rates: $0.03/1K input, $0.06/1K output
|
||||
|
||||
USD Cost = (2000/1000 × 0.03) + (500/1000 × 0.06)
|
||||
= $0.06 + $0.03
|
||||
= $0.09
|
||||
|
||||
At $50,000/BTC: 180 sats
|
||||
With 5.5% total fees: 190 sats
|
||||
```
|
||||
|
||||
**Example 3: Image Generation (dall-e-3)**
|
||||
|
||||
```
|
||||
Model: dall-e-3
|
||||
Size: 1024x1024
|
||||
Quality: standard
|
||||
Cost: $0.04 per image
|
||||
|
||||
At $50,000/BTC: 80 sats
|
||||
With 5.5% fees: 84 sats
|
||||
```
|
||||
|
||||
## Fee Structure
|
||||
|
||||
### Exchange Fee
|
||||
|
||||
Covers Bitcoin/USD conversion costs:
|
||||
|
||||
```bash
|
||||
EXCHANGE_FEE=1.005 # 0.5% default
|
||||
```
|
||||
|
||||
Factors:
|
||||
|
||||
- Exchange rate volatility
|
||||
- Conversion costs
|
||||
- Price update frequency
|
||||
|
||||
### Provider Fee
|
||||
|
||||
Node operator's margin:
|
||||
|
||||
```bash
|
||||
UPSTREAM_PROVIDER_FEE=1.05 # 5% default
|
||||
```
|
||||
|
||||
Covers:
|
||||
|
||||
- Infrastructure costs
|
||||
- Maintenance
|
||||
- Support
|
||||
- Profit margin
|
||||
|
||||
### Calculating Total Fees
|
||||
|
||||
```
|
||||
Total Multiplier = EXCHANGE_FEE × UPSTREAM_PROVIDER_FEE
|
||||
Example: 1.005 × 1.05 = 1.05525 (5.525% total)
|
||||
```
|
||||
|
||||
## Special Pricing
|
||||
|
||||
### Image Models
|
||||
|
||||
Image generation uses per-image pricing:
|
||||
|
||||
| Model | Size | Quality | Price |
|
||||
|-------|------|---------|-------|
|
||||
| dall-e-2 | 256x256 | - | $0.016 |
|
||||
| dall-e-2 | 512x512 | - | $0.018 |
|
||||
| dall-e-2 | 1024x1024 | - | $0.02 |
|
||||
| dall-e-3 | 1024x1024 | standard | $0.04 |
|
||||
| dall-e-3 | 1024x1024 | hd | $0.08 |
|
||||
| dall-e-3 | 1024x1792 | standard | $0.08 |
|
||||
| dall-e-3 | 1024x1792 | hd | $0.12 |
|
||||
|
||||
### Audio Models
|
||||
|
||||
Audio pricing by duration:
|
||||
|
||||
| Model | Type | Price |
|
||||
|-------|------|-------|
|
||||
| whisper-1 | Transcription | $0.006/minute |
|
||||
| whisper-1 | Translation | $0.006/minute |
|
||||
| tts-1 | Text-to-speech | $0.015/1K chars |
|
||||
| tts-1-hd | HD speech | $0.03/1K chars |
|
||||
|
||||
### Embedding Models
|
||||
|
||||
Lower costs for embeddings:
|
||||
|
||||
| Model | Price/1K tokens |
|
||||
|-------|-----------------|
|
||||
| text-embedding-3-small | $0.00002 |
|
||||
| text-embedding-3-large | $0.00013 |
|
||||
| text-embedding-ada-002 | $0.0001 |
|
||||
|
||||
## Monitoring Costs
|
||||
|
||||
### Per-Request Tracking
|
||||
|
||||
Each API response includes usage data:
|
||||
|
||||
```json
|
||||
{
|
||||
"usage": {
|
||||
"prompt_tokens": 50,
|
||||
"completion_tokens": 150,
|
||||
"total_tokens": 200
|
||||
},
|
||||
"x-routstr-cost": {
|
||||
"sats": 79,
|
||||
"usd": 0.000375,
|
||||
"breakdown": {
|
||||
"prompt_cost": 15,
|
||||
"completion_cost": 60,
|
||||
"fees": 4
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Daily Summaries
|
||||
|
||||
View in admin dashboard:
|
||||
|
||||
- Total requests
|
||||
- Token usage by model
|
||||
- Cost distribution
|
||||
- Trending patterns
|
||||
|
||||
### Cost Alerts
|
||||
|
||||
Set up notifications:
|
||||
|
||||
```python
|
||||
# Example monitoring script
|
||||
def check_daily_spend(api_key):
|
||||
balance_start = get_balance(api_key, "00:00")
|
||||
balance_now = get_balance(api_key)
|
||||
spent = balance_start - balance_now
|
||||
|
||||
if spent > DAILY_LIMIT:
|
||||
send_alert(f"Daily spend exceeded: {spent} sats")
|
||||
```
|
||||
|
||||
## Optimization Strategies
|
||||
|
||||
### Model Selection
|
||||
|
||||
Choose the right model for each task:
|
||||
|
||||
| Task | Recommended Model | Why |
|
||||
|------|-------------------|-----|
|
||||
| Simple Q&A | gpt-3.5-turbo | Fast, cheap, sufficient |
|
||||
| Code generation | gpt-4 | Better reasoning |
|
||||
| Summarization | claude-3-haiku | Good balance |
|
||||
| Creative writing | claude-3-opus | Best quality |
|
||||
| Embeddings | text-embedding-3-small | Optimized for vectors |
|
||||
|
||||
### Prompt Engineering
|
||||
|
||||
Reduce costs with efficient prompts:
|
||||
|
||||
```python
|
||||
# Expensive
|
||||
prompt = """
|
||||
You are an AI assistant. Your task is to help users.
|
||||
Please provide detailed, comprehensive answers.
|
||||
Now, answer this question: What is 2+2?
|
||||
"""
|
||||
|
||||
# Economical
|
||||
prompt = "Calculate: 2+2"
|
||||
```
|
||||
|
||||
### Caching Strategies
|
||||
|
||||
Implement smart caching:
|
||||
|
||||
```python
|
||||
# Cache embedding results
|
||||
@lru_cache(maxsize=1000)
|
||||
def get_embedding(text):
|
||||
return client.embeddings.create(
|
||||
model="text-embedding-3-small",
|
||||
input=text
|
||||
)
|
||||
|
||||
# Cache common responses
|
||||
COMMON_RESPONSES = {
|
||||
"greeting": "Hello! How can I help you?",
|
||||
"goodbye": "Goodbye! Have a great day!"
|
||||
}
|
||||
```
|
||||
|
||||
### Batch Processing
|
||||
|
||||
Process multiple items efficiently:
|
||||
|
||||
```python
|
||||
# Instead of multiple calls
|
||||
for item in items:
|
||||
response = client.chat.completions.create(...)
|
||||
|
||||
# Use single call with formatted prompt
|
||||
prompt = "\n".join([f"{i+1}. {item}" for i, item in enumerate(items)])
|
||||
response = client.chat.completions.create(
|
||||
messages=[{"role": "user", "content": f"Process these items:\n{prompt}"}]
|
||||
)
|
||||
```
|
||||
|
||||
## Custom Pricing Rules
|
||||
|
||||
### Time-Based Pricing
|
||||
|
||||
Implement off-peak discounts:
|
||||
|
||||
```python
|
||||
def calculate_multiplier():
|
||||
hour = datetime.now().hour
|
||||
if 2 <= hour <= 6: # 2 AM - 6 AM
|
||||
return 0.8 # 20% discount
|
||||
elif 18 <= hour <= 22: # 6 PM - 10 PM
|
||||
return 1.2 # 20% premium
|
||||
return 1.0
|
||||
```
|
||||
|
||||
### Model-Specific Rules
|
||||
|
||||
Custom pricing logic:
|
||||
|
||||
```python
|
||||
def adjust_model_price(model, base_price):
|
||||
# Premium for latest models
|
||||
if "turbo" in model or "latest" in model:
|
||||
return base_price * 1.1
|
||||
|
||||
# Discount for older models
|
||||
if "legacy" in model:
|
||||
return base_price * 0.8
|
||||
|
||||
return base_price
|
||||
```
|
||||
|
||||
## Pricing Transparency
|
||||
|
||||
### Public Pricing Page
|
||||
|
||||
Display current rates:
|
||||
|
||||
```html
|
||||
<!-- Available at /pricing -->
|
||||
<table>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<th>Input (sats/1K)</th>
|
||||
<th>Output (sats/1K)</th>
|
||||
</tr>
|
||||
<!-- Dynamically generated from models.json -->
|
||||
</table>
|
||||
```
|
||||
|
||||
### Cost Estimation API
|
||||
|
||||
Provide cost estimates:
|
||||
|
||||
```bash
|
||||
POST /v1/estimate
|
||||
{
|
||||
"model": "gpt-4",
|
||||
"prompt_tokens": 500,
|
||||
"max_tokens": 200
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"estimated_cost_sats": 45,
|
||||
"breakdown": {
|
||||
"prompt": 30,
|
||||
"completion": 12,
|
||||
"fees": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Pricing Mismatches
|
||||
|
||||
**Issue**: Costs don't match expectations
|
||||
|
||||
- Check current BTC/USD rate
|
||||
- Verify fee settings
|
||||
- Review model configuration
|
||||
|
||||
**Issue**: Models not found
|
||||
|
||||
- Update models.json
|
||||
- Check model ID spelling
|
||||
- Verify upstream support
|
||||
|
||||
### Fee Calculations
|
||||
|
||||
**Issue**: Fees seem too high
|
||||
|
||||
- Review EXCHANGE_FEE setting
|
||||
- Check UPSTREAM_PROVIDER_FEE
|
||||
- Calculate total multiplier
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [API Reference](../api/overview.md) - Technical details
|
||||
- [Custom Pricing](../advanced/custom-pricing.md) - Advanced configuration
|
||||
- [Contributing](../contributing/setup.md) - Help improve Routstr
|
||||
@@ -0,0 +1,373 @@
|
||||
# Payment Flow
|
||||
|
||||
Understanding how payments work in Routstr is key to using the system effectively. This guide explains the payment process in detail.
|
||||
|
||||
## Overview
|
||||
|
||||
Routstr uses a pre-funded account model where:
|
||||
|
||||
1. Users deposit eCash tokens to create an API key
|
||||
2. Each API request deducts from the balance
|
||||
3. Users can withdraw remaining balance as eCash
|
||||
|
||||
## Creating an API Key
|
||||
|
||||
### Step 1: Obtain eCash Token
|
||||
|
||||
Get a Cashu token from any compatible source:
|
||||
|
||||
**Option A: From a Cashu Wallet**
|
||||
|
||||
```bash
|
||||
# Example: Creating a 10,000 sat token
|
||||
cashu send 10000
|
||||
```
|
||||
|
||||
**Option B: Lightning Invoice**
|
||||
|
||||
```bash
|
||||
# Some mints support direct Lightning deposits
|
||||
curl -X POST https://mint.example.com/v1/mint/quote/bolt11 \
|
||||
-d '{"amount": 10000, "unit": "sat"}'
|
||||
```
|
||||
|
||||
**Option C: Test Tokens**
|
||||
|
||||
```bash
|
||||
# Get test tokens from testnet mints
|
||||
# Check mint documentation for faucets
|
||||
```
|
||||
|
||||
### Step 2: Create API Key
|
||||
|
||||
**Note: The POST /v1/wallet/create endpoint is coming soon. Currently, you can use Cashu tokens directly as API keys in the Authorization header.**
|
||||
|
||||
Send your token to Routstr:
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.routstr.com/v1/wallet/create \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"cashu_token": "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vbWlu..."
|
||||
}'
|
||||
```
|
||||
|
||||
**Request Parameters:**
|
||||
|
||||
- `cashu_token` (required): The eCash token to deposit
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "sk-1234567890abcdef",
|
||||
"balance": 10000000,
|
||||
"created_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Verify Balance
|
||||
|
||||
Check your key's balance:
|
||||
|
||||
```bash
|
||||
curl -X GET https://api.routstr.com/v1/wallet/balance \
|
||||
-H "Authorization: Bearer sk-1234567890abcdef"
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"balance": 10000000,
|
||||
"total_deposited": 10000000,
|
||||
"total_spent": 0,
|
||||
"last_used": null
|
||||
}
|
||||
```
|
||||
|
||||
## Making API Requests
|
||||
|
||||
### Cost Calculation
|
||||
|
||||
Costs are calculated based on:
|
||||
|
||||
1. **Request Type**
|
||||
- Chat completions
|
||||
- Embeddings
|
||||
- Image generation
|
||||
- Audio processing
|
||||
|
||||
2. **Token Usage**
|
||||
- Input tokens (prompt)
|
||||
- Output tokens (response)
|
||||
- Model-specific rates
|
||||
|
||||
3. **Additional Costs**
|
||||
- Base request fee
|
||||
- Image generation fees
|
||||
- Audio processing time
|
||||
|
||||
### Example: Chat Completion
|
||||
|
||||
```python
|
||||
import openai
|
||||
|
||||
client = openai.OpenAI(
|
||||
api_key="sk-1234567890abcdef",
|
||||
base_url="https://api.routstr.com/v1"
|
||||
)
|
||||
|
||||
# Make request
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[
|
||||
{"role": "user", "content": "Hello, how are you?"}
|
||||
]
|
||||
)
|
||||
|
||||
# Check usage
|
||||
print(f"Input tokens: {response.usage.prompt_tokens}")
|
||||
print(f"Output tokens: {response.usage.completion_tokens}")
|
||||
print(f"Total tokens: {response.usage.total_tokens}")
|
||||
```
|
||||
|
||||
### Cost Breakdown
|
||||
|
||||
For the above request:
|
||||
|
||||
```
|
||||
Model: gpt-3.5-turbo
|
||||
Input tokens: 13
|
||||
Output tokens: 27
|
||||
Model rates: $0.0015/1K input, $0.002/1K output
|
||||
|
||||
USD Cost = (13/1000 * 0.0015) + (27/1000 * 0.002) = $0.0000735
|
||||
BTC/USD Rate: $50,000
|
||||
BTC Cost = 0.0000735 / 50000 = 0.00000000147 BTC = 147 sats
|
||||
With fees (5%): 154 sats
|
||||
|
||||
Final cost: 154 sats
|
||||
```
|
||||
|
||||
## Balance Management
|
||||
|
||||
### Monitoring Usage
|
||||
|
||||
Track your usage in real-time:
|
||||
|
||||
```bash
|
||||
# Get current balance
|
||||
curl -X GET https://api.routstr.com/v1/wallet/balance \
|
||||
-H "Authorization: Bearer your-api-key"
|
||||
|
||||
# View recent transactions (through admin dashboard)
|
||||
# Access at https://api.routstr.com/admin/
|
||||
```
|
||||
|
||||
### Low Balance Handling
|
||||
|
||||
When balance is insufficient:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "insufficient_balance",
|
||||
"message": "Insufficient balance. Current: 100 sats, Required: 154 sats",
|
||||
"code": "payment_required"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Topping Up
|
||||
|
||||
Add funds to existing key:
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.routstr.com/v1/wallet/topup \
|
||||
-H "Authorization: Bearer your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"cashu_token": "cashuAeyJ0b2..."
|
||||
}'
|
||||
```
|
||||
|
||||
## Withdrawing Balance
|
||||
|
||||
### Via Admin Dashboard
|
||||
|
||||
1. Navigate to `/admin/`
|
||||
2. Enter admin password
|
||||
3. Find your API key
|
||||
4. Click "Withdraw"
|
||||
5. Receive eCash token
|
||||
|
||||
### Via API (if enabled)
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.routstr.com/v1/wallet/withdraw \
|
||||
-H "Authorization: Bearer your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"amount": 5000,
|
||||
"mint": "https://mint.minibits.cash/Bitcoin"
|
||||
}'
|
||||
```
|
||||
|
||||
## Payment Security
|
||||
|
||||
### Token Validation
|
||||
|
||||
Routstr validates tokens by:
|
||||
|
||||
1. Checking signature validity
|
||||
2. Verifying with the issuing mint
|
||||
3. Ensuring no double-spending
|
||||
4. Confirming sufficient value
|
||||
|
||||
### Failed Payments
|
||||
|
||||
Common failure reasons:
|
||||
|
||||
- Invalid token signature
|
||||
- Already spent token
|
||||
- Untrusted mint
|
||||
- Network issues with mint
|
||||
|
||||
### Refund Policy
|
||||
|
||||
- Unused balance can be withdrawn anytime
|
||||
- Expired keys with balance can be refunded
|
||||
- Node operators may have additional policies
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Multi-Mint Support
|
||||
|
||||
Routstr accepts tokens from multiple mints:
|
||||
|
||||
```bash
|
||||
CASHU_MINTS=https://mint1.com,https://mint2.com,https://mint3.com
|
||||
```
|
||||
|
||||
Benefits:
|
||||
|
||||
- Redundancy if one mint is down
|
||||
- User choice of mints
|
||||
- Geographic distribution
|
||||
|
||||
### Automatic Payouts
|
||||
|
||||
Configure automatic Lightning payouts:
|
||||
|
||||
```bash
|
||||
RECEIVE_LN_ADDRESS=satoshi@getalby.com
|
||||
```
|
||||
|
||||
When enabled:
|
||||
|
||||
- Balances above threshold are swept
|
||||
- Converted to Lightning payments
|
||||
- Sent to configured address
|
||||
|
||||
### Per-Request Payments (Coming Soon)
|
||||
|
||||
Future support for Nut-24 headers:
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.routstr.com/v1/chat/completions \
|
||||
-H "x-cashu: cashuAeyJ0..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{...}'
|
||||
```
|
||||
|
||||
Response includes change:
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
x-cashu: cashuAeyJjaGFuZ2Ui...
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### API Key Management
|
||||
|
||||
1. **Separate Keys per Application**
|
||||
- Easier tracking
|
||||
- Better security
|
||||
- Independent budgets
|
||||
|
||||
2. **Set Expiration Dates**
|
||||
- Automatic cleanup
|
||||
- Security improvement
|
||||
- Budget control
|
||||
|
||||
3. **Monitor Balances**
|
||||
- Set up alerts
|
||||
- Regular checks
|
||||
- Usage analytics
|
||||
|
||||
### Cost Optimization
|
||||
|
||||
1. **Choose Appropriate Models**
|
||||
- Smaller models for simple tasks
|
||||
- Larger models only when needed
|
||||
|
||||
2. **Optimize Prompts**
|
||||
- Concise, clear instructions
|
||||
- Avoid unnecessary tokens
|
||||
|
||||
3. **Use Streaming**
|
||||
- Early termination possible
|
||||
- Better user experience
|
||||
|
||||
### Security
|
||||
|
||||
1. **Secure Storage**
|
||||
- Environment variables
|
||||
- Secrets management
|
||||
- Never in code
|
||||
|
||||
2. **Network Security**
|
||||
- Always use HTTPS
|
||||
- Verify certificates
|
||||
- Consider Tor for privacy
|
||||
|
||||
3. **Regular Rotation**
|
||||
- Change keys periodically
|
||||
- Withdraw unused funds
|
||||
- Audit usage logs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Payment Rejected
|
||||
|
||||
**Error:** "Invalid token"
|
||||
|
||||
- Check token format
|
||||
- Verify mint is trusted
|
||||
- Ensure not already spent
|
||||
|
||||
**Error:** "Insufficient value"
|
||||
|
||||
- Token value too low
|
||||
- Check current pricing
|
||||
- Add larger token
|
||||
|
||||
### Balance Discrepancies
|
||||
|
||||
- Allow for price fluctuations
|
||||
- Check model pricing updates
|
||||
- Review transaction history
|
||||
|
||||
### Mint Issues
|
||||
|
||||
- Try different mint from list
|
||||
- Check mint status
|
||||
- Contact mint operator
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Using the API](using-api.md) - Integration guide
|
||||
- [Admin Dashboard](admin-dashboard.md) - Account management
|
||||
- [Models & Pricing](models-pricing.md) - Cost details
|
||||
@@ -0,0 +1,536 @@
|
||||
# Using the API
|
||||
|
||||
This guide shows how to integrate Routstr with your applications using various programming languages and tools.
|
||||
|
||||
## API Compatibility
|
||||
|
||||
Routstr maintains full compatibility with the OpenAI API, meaning:
|
||||
|
||||
- Existing OpenAI client libraries work without modification
|
||||
- Only the base URL and API key need to change
|
||||
- All parameters and responses match OpenAI's format
|
||||
|
||||
## Basic Setup
|
||||
|
||||
### Python
|
||||
|
||||
Using the official OpenAI Python library:
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
# Initialize client with Routstr endpoint
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="https://api.routstr.com/v1"
|
||||
)
|
||||
|
||||
# Use exactly like OpenAI
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello!"}
|
||||
]
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)
|
||||
```
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
Using the official OpenAI Node.js library:
|
||||
|
||||
```javascript
|
||||
import OpenAI from 'openai';
|
||||
|
||||
// Initialize client
|
||||
const openai = new OpenAI({
|
||||
apiKey: 'sk-...',
|
||||
baseURL: 'https://api.routstr.com/v1'
|
||||
});
|
||||
|
||||
// Make a request
|
||||
async function main() {
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-3.5-turbo',
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are a helpful assistant.' },
|
||||
{ role: 'user', content: 'Hello!' }
|
||||
]
|
||||
});
|
||||
|
||||
console.log(completion.choices[0].message.content);
|
||||
}
|
||||
|
||||
main();
|
||||
```
|
||||
|
||||
### cURL
|
||||
|
||||
Direct HTTP requests:
|
||||
|
||||
```bash
|
||||
curl https://api.routstr.com/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer sk-..." \
|
||||
-d '{
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello!"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### Chat Completions
|
||||
|
||||
Standard chat with conversation history:
|
||||
|
||||
```python
|
||||
messages = []
|
||||
|
||||
def chat(user_input):
|
||||
# Add user message
|
||||
messages.append({"role": "user", "content": user_input})
|
||||
|
||||
# Get AI response
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=messages,
|
||||
temperature=0.7,
|
||||
max_tokens=150
|
||||
)
|
||||
|
||||
# Add AI response to history
|
||||
ai_message = response.choices[0].message
|
||||
messages.append({"role": "assistant", "content": ai_message.content})
|
||||
|
||||
return ai_message.content
|
||||
|
||||
# Usage
|
||||
print(chat("What's the weather like?"))
|
||||
print(chat("How should I dress?")) # Maintains context
|
||||
```
|
||||
|
||||
### Streaming Responses
|
||||
|
||||
For real-time output:
|
||||
|
||||
```python
|
||||
stream = client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Write a short story"}],
|
||||
stream=True
|
||||
)
|
||||
|
||||
for chunk in stream:
|
||||
if chunk.choices[0].delta.content is not None:
|
||||
print(chunk.choices[0].delta.content, end="", flush=True)
|
||||
```
|
||||
|
||||
### Function Calling
|
||||
|
||||
Using OpenAI's function calling feature:
|
||||
|
||||
```python
|
||||
tools = [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get current weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"type": "string"},
|
||||
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
|
||||
},
|
||||
"required": ["location"]
|
||||
}
|
||||
}
|
||||
}]
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4",
|
||||
messages=[{"role": "user", "content": "What's the weather in Tokyo?"}],
|
||||
tools=tools,
|
||||
tool_choice="auto"
|
||||
)
|
||||
|
||||
# Check if function was called
|
||||
if response.choices[0].message.tool_calls:
|
||||
tool_call = response.choices[0].message.tool_calls[0]
|
||||
print(f"Function: {tool_call.function.name}")
|
||||
print(f"Arguments: {tool_call.function.arguments}")
|
||||
```
|
||||
|
||||
### Embeddings
|
||||
|
||||
Generate text embeddings:
|
||||
|
||||
```python
|
||||
response = client.embeddings.create(
|
||||
model="text-embedding-3-small",
|
||||
input="The quick brown fox jumps over the lazy dog"
|
||||
)
|
||||
|
||||
embedding = response.data[0].embedding
|
||||
print(f"Embedding dimension: {len(embedding)}")
|
||||
```
|
||||
|
||||
### Image Generation
|
||||
|
||||
Create images with DALL-E:
|
||||
|
||||
```python
|
||||
response = client.images.generate(
|
||||
model="dall-e-3",
|
||||
prompt="A futuristic city with flying cars",
|
||||
size="1024x1024",
|
||||
quality="standard",
|
||||
n=1
|
||||
)
|
||||
|
||||
image_url = response.data[0].url
|
||||
print(f"Image URL: {image_url}")
|
||||
```
|
||||
|
||||
### Audio Transcription
|
||||
|
||||
Convert speech to text:
|
||||
|
||||
```python
|
||||
with open("audio.mp3", "rb") as audio_file:
|
||||
response = client.audio.transcriptions.create(
|
||||
model="whisper-1",
|
||||
file=audio_file,
|
||||
response_format="text"
|
||||
)
|
||||
|
||||
print(response.text)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Balance Errors
|
||||
|
||||
Handle insufficient balance gracefully:
|
||||
|
||||
```python
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4",
|
||||
messages=[{"role": "user", "content": "Hello"}]
|
||||
)
|
||||
except Exception as e:
|
||||
if "insufficient_balance" in str(e):
|
||||
print("Low balance! Please top up your API key.")
|
||||
# Implement top-up logic
|
||||
else:
|
||||
raise
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Implement exponential backoff:
|
||||
|
||||
```python
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
def make_request_with_retry(
|
||||
func,
|
||||
max_retries: int = 3,
|
||||
initial_delay: float = 1.0
|
||||
) -> Optional[any]:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return func()
|
||||
except Exception as e:
|
||||
if "rate_limit" in str(e) and attempt < max_retries - 1:
|
||||
delay = initial_delay * (2 ** attempt)
|
||||
print(f"Rate limited. Waiting {delay}s...")
|
||||
time.sleep(delay)
|
||||
else:
|
||||
raise
|
||||
return None
|
||||
```
|
||||
|
||||
### Connection Errors
|
||||
|
||||
Handle network issues:
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
# Configure timeout and retries
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="https://your-node.com/v1",
|
||||
timeout=httpx.Timeout(60.0, connect=5.0),
|
||||
max_retries=2
|
||||
)
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Using Tor
|
||||
|
||||
Route requests through Tor for privacy:
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
# Configure Tor proxy
|
||||
proxies = {
|
||||
"http://": "socks5://127.0.0.1:9050",
|
||||
"https://": "socks5://127.0.0.1:9050"
|
||||
}
|
||||
|
||||
http_client = httpx.Client(proxies=proxies)
|
||||
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="http://your-onion-address.onion/v1",
|
||||
http_client=http_client
|
||||
)
|
||||
```
|
||||
|
||||
### Custom Headers
|
||||
|
||||
Add custom headers if needed:
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
class CustomClient(httpx.Client):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.headers["X-Custom-Header"] = "value"
|
||||
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="https://your-node.com/v1",
|
||||
http_client=CustomClient()
|
||||
)
|
||||
```
|
||||
|
||||
### Async Operations
|
||||
|
||||
For high-performance applications:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
async_client = AsyncOpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="https://your-node.com/v1"
|
||||
)
|
||||
|
||||
async def process_messages(messages):
|
||||
tasks = []
|
||||
for msg in messages:
|
||||
task = async_client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": msg}]
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
responses = await asyncio.gather(*tasks)
|
||||
return [r.choices[0].message.content for r in responses]
|
||||
|
||||
# Run async
|
||||
messages = ["Hello", "How are you?", "What's 2+2?"]
|
||||
results = asyncio.run(process_messages(messages))
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Environment Variables
|
||||
|
||||
Never hardcode API keys:
|
||||
|
||||
```python
|
||||
import os
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.getenv("ROUTSTR_API_KEY"),
|
||||
base_url=os.getenv("ROUTSTR_BASE_URL", "https://api.routstr.com/v1")
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Error Logging
|
||||
|
||||
Implement comprehensive logging:
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(...)
|
||||
logger.info(f"Request successful. Tokens used: {response.usage.total_tokens}")
|
||||
except Exception as e:
|
||||
logger.error(f"API request failed: {e}")
|
||||
raise
|
||||
```
|
||||
|
||||
### 3. Cost Tracking
|
||||
|
||||
Monitor your usage:
|
||||
|
||||
```python
|
||||
class UsageTracker:
|
||||
def __init__(self):
|
||||
self.total_tokens = 0
|
||||
self.total_requests = 0
|
||||
|
||||
def track(self, response):
|
||||
self.total_tokens += response.usage.total_tokens
|
||||
self.total_requests += 1
|
||||
|
||||
# Estimate cost (example rates)
|
||||
cost_per_1k = 0.002 # $0.002 per 1K tokens
|
||||
estimated_cost = (self.total_tokens / 1000) * cost_per_1k
|
||||
|
||||
logger.info(f"Total usage: {self.total_tokens} tokens, "
|
||||
f"${estimated_cost:.4f} (~{estimated_cost * 50000:.0f} sats)")
|
||||
|
||||
tracker = UsageTracker()
|
||||
response = client.chat.completions.create(...)
|
||||
tracker.track(response)
|
||||
```
|
||||
|
||||
### 4. Caching Responses
|
||||
|
||||
Reduce costs with intelligent caching:
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
import json
|
||||
from functools import lru_cache
|
||||
|
||||
@lru_cache(maxsize=100)
|
||||
def cached_completion(prompt: str, model: str = "gpt-3.5-turbo"):
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0 # Deterministic for caching
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
|
||||
# Repeated calls with same prompt use cache
|
||||
result1 = cached_completion("What is 2+2?")
|
||||
result2 = cached_completion("What is 2+2?") # From cache, no API call
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Mock Responses
|
||||
|
||||
For development without spending sats:
|
||||
|
||||
```python
|
||||
class MockOpenAI:
|
||||
class Completions:
|
||||
def create(self, **kwargs):
|
||||
return type('Response', (), {
|
||||
'choices': [type('Choice', (), {
|
||||
'message': type('Message', (), {
|
||||
'content': 'Mock response'
|
||||
})()
|
||||
})],
|
||||
'usage': type('Usage', (), {
|
||||
'total_tokens': 10
|
||||
})()
|
||||
})()
|
||||
|
||||
def __init__(self):
|
||||
self.chat = type('Chat', (), {
|
||||
'completions': self.Completions()
|
||||
})()
|
||||
|
||||
# Use mock in tests
|
||||
if os.getenv('TESTING'):
|
||||
client = MockOpenAI()
|
||||
else:
|
||||
client = OpenAI(...)
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Test your Routstr integration:
|
||||
|
||||
```python
|
||||
def test_routstr_connection():
|
||||
try:
|
||||
# Test models endpoint
|
||||
models = client.models.list()
|
||||
assert len(models.data) > 0
|
||||
|
||||
# Test simple completion
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
max_tokens=5
|
||||
)
|
||||
assert response.choices[0].message.content
|
||||
|
||||
print("✅ Routstr integration working!")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ Integration test failed: {e}")
|
||||
return False
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**SSL Certificate Errors**
|
||||
|
||||
```python
|
||||
# For development only - not for production!
|
||||
import ssl
|
||||
import httpx
|
||||
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="https://localhost:8000/v1",
|
||||
http_client=httpx.Client(verify=False)
|
||||
)
|
||||
```
|
||||
|
||||
**Timeout Issues**
|
||||
|
||||
```python
|
||||
# Increase timeout for slow connections
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="https://your-node.com/v1",
|
||||
timeout=httpx.Timeout(120.0) # 2 minutes
|
||||
)
|
||||
```
|
||||
|
||||
**Debugging Requests**
|
||||
|
||||
```python
|
||||
import logging
|
||||
import httpx
|
||||
|
||||
# Enable debug logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
httpx_logger = logging.getLogger("httpx")
|
||||
httpx_logger.setLevel(logging.DEBUG)
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Admin Dashboard](admin-dashboard.md) - Manage your account
|
||||
- [Models & Pricing](models-pricing.md) - Understanding costs
|
||||
- [API Reference](../api/overview.md) - Technical details
|
||||
+2
-2
@@ -9,11 +9,11 @@ from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from routstr.core.db import DATABASE_URL
|
||||
|
||||
# Add the parent directory to the Python path so we can import routstr modules
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||
|
||||
from routstr.core.db import DATABASE_URL
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is None:
|
||||
raise ValueError("config_file_name is None")
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""introduce reserved balance
|
||||
|
||||
Revision ID: 042f6b77d69d
|
||||
Revises: 898f00ea481e
|
||||
Create Date: 2025-08-18 19:03:09.507368
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "042f6b77d69d"
|
||||
down_revision = "898f00ea481e"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"api_keys",
|
||||
sa.Column("reserved_balance", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("api_keys", "reserved_balance")
|
||||
# ### end Alembic commands ###
|
||||
@@ -6,8 +6,8 @@ Create Date: 2025-08-09 13:48:40.648729
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
from sqlmodel.sql import sqltypes
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "7bc4e8b02b9d"
|
||||
@@ -20,7 +20,7 @@ def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"api_keys",
|
||||
sa.Column("mint_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("mint_url", sqltypes.AutoString(), nullable=True),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ Create Date: 2025-08-13 16:45:42.148314
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
from sqlmodel.sql import sqltypes
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "898f00ea481e"
|
||||
@@ -20,11 +20,11 @@ def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"api_keys",
|
||||
sa.Column("refund_mint_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("refund_mint_url", sqltypes.AutoString(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"api_keys",
|
||||
sa.Column("refund_currency", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("refund_currency", sqltypes.AutoString(), nullable=True),
|
||||
)
|
||||
op.drop_column("api_keys", "mint_url")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
@@ -6,8 +6,8 @@ Create Date: 2025-08-09 13:28:38.537652
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel as sqlm
|
||||
from alembic import op
|
||||
from sqlmodel.sql import sqltypes
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f6ce1348e266"
|
||||
@@ -20,9 +20,9 @@ def upgrade() -> None:
|
||||
if "api_keys" not in sa.inspect(op.get_bind()).get_table_names():
|
||||
op.create_table(
|
||||
"api_keys",
|
||||
sa.Column("hashed_key", sqlm.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("hashed_key", sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("balance", sa.Integer(), nullable=False),
|
||||
sa.Column("refund_address", sqlm.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("refund_address", sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("key_expiry_time", sa.Integer(), nullable=True),
|
||||
sa.Column("total_spent", sa.Integer(), nullable=False),
|
||||
sa.Column("total_requests", sa.Integer(), nullable=False),
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
site_name: Routstr Core Documentation
|
||||
site_url: https://docs.routstr.com
|
||||
site_description: FastAPI-based reverse proxy for OpenAI-compatible APIs with Bitcoin eCash micropayments
|
||||
site_author: Routstr Team
|
||||
|
||||
repo_name: routstr/routstr-core
|
||||
repo_url: https://github.com/routstr/routstr-core
|
||||
edit_uri: tree/main/docs
|
||||
|
||||
theme:
|
||||
name: material
|
||||
language: en
|
||||
palette:
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
primary: orange
|
||||
accent: amber
|
||||
toggle:
|
||||
icon: material/brightness-7
|
||||
name: Switch to dark mode
|
||||
- media: "(prefers-color-scheme: dark)"
|
||||
scheme: slate
|
||||
primary: orange
|
||||
accent: amber
|
||||
toggle:
|
||||
icon: material/brightness-4
|
||||
name: Switch to light mode
|
||||
features:
|
||||
- navigation.tabs
|
||||
- navigation.sections
|
||||
- navigation.expand
|
||||
- navigation.top
|
||||
- navigation.indexes
|
||||
- search.suggest
|
||||
- search.highlight
|
||||
- content.tabs.link
|
||||
- content.code.copy
|
||||
- content.code.annotate
|
||||
icon:
|
||||
logo: material/lightning-bolt
|
||||
repo: fontawesome/brands/github
|
||||
|
||||
plugins:
|
||||
- search
|
||||
- mermaid2
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- codehilite:
|
||||
guess_lang: false
|
||||
- toc:
|
||||
permalink: true
|
||||
- pymdownx.superfences:
|
||||
custom_fences:
|
||||
- name: mermaid
|
||||
class: mermaid
|
||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||
- pymdownx.tabbed:
|
||||
alternate_style: true
|
||||
- pymdownx.emoji:
|
||||
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
||||
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
||||
- pymdownx.details
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.snippets
|
||||
- pymdownx.tasklist:
|
||||
custom_checkbox: true
|
||||
- attr_list
|
||||
- md_in_html
|
||||
|
||||
extra:
|
||||
social:
|
||||
- icon: fontawesome/brands/github
|
||||
link: https://github.com/routstr
|
||||
- icon: fontawesome/brands/bitcoin
|
||||
link: https://cashu.space
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Getting Started:
|
||||
- Overview: getting-started/overview.md
|
||||
- Quick Start: getting-started/quickstart.md
|
||||
- Docker Setup: getting-started/docker.md
|
||||
- Configuration: getting-started/configuration.md
|
||||
- User Guide:
|
||||
- Introduction: user-guide/introduction.md
|
||||
- Payment Flow: user-guide/payment-flow.md
|
||||
- Using the API: user-guide/using-api.md
|
||||
- Admin Dashboard: user-guide/admin-dashboard.md
|
||||
- Models & Pricing: user-guide/models-pricing.md
|
||||
- Contributing:
|
||||
- Setup Development: contributing/setup.md
|
||||
- Architecture: contributing/architecture.md
|
||||
- Code Structure: contributing/code-structure.md
|
||||
- Testing: contributing/testing.md
|
||||
- API Reference:
|
||||
- Overview: api/overview.md
|
||||
- Authentication: api/authentication.md
|
||||
- Endpoints: api/endpoints.md
|
||||
- Errors: api/errors.md
|
||||
- Advanced:
|
||||
- Tor Support: advanced/tor.md
|
||||
- Nostr Discovery: advanced/nostr.md
|
||||
- Custom Pricing: advanced/custom-pricing.md
|
||||
- Migrations: advanced/migrations.md
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "routstr"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1b"
|
||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+43
-18
@@ -1,4 +1,5 @@
|
||||
import hashlib
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -12,7 +13,6 @@ from .payment.cost_caculation import (
|
||||
MaxCostData,
|
||||
calculate_cost,
|
||||
)
|
||||
from .payment.helpers import get_max_cost_for_model
|
||||
from .wallet import (
|
||||
PRIMARY_MINT_URL,
|
||||
TRUSTED_MINTS,
|
||||
@@ -271,10 +271,10 @@ async def validate_bearer_key(
|
||||
)
|
||||
|
||||
|
||||
async def pay_for_request(key: ApiKey, session: AsyncSession, body: dict) -> int:
|
||||
async def pay_for_request(
|
||||
key: ApiKey, cost_per_request: int, session: AsyncSession
|
||||
) -> int:
|
||||
"""Process payment for a request."""
|
||||
model = body["model"]
|
||||
cost_per_request = get_max_cost_for_model(model=model)
|
||||
|
||||
logger.info(
|
||||
"Processing payment for request",
|
||||
@@ -282,20 +282,19 @@ async def pay_for_request(key: ApiKey, session: AsyncSession, body: dict) -> int
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"current_balance": key.balance,
|
||||
"required_cost": cost_per_request,
|
||||
"model": model,
|
||||
"sufficient_balance": key.balance >= cost_per_request,
|
||||
},
|
||||
)
|
||||
|
||||
if key.balance < cost_per_request:
|
||||
if key.total_balance < cost_per_request:
|
||||
logger.warning(
|
||||
"Insufficient balance for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"balance": key.balance,
|
||||
"reserved_balance": key.reserved_balance,
|
||||
"required": cost_per_request,
|
||||
"shortfall": cost_per_request - key.balance,
|
||||
"model": model,
|
||||
"shortfall": cost_per_request - key.total_balance,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -303,7 +302,7 @@ async def pay_for_request(key: ApiKey, session: AsyncSession, body: dict) -> int
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.total_balance} available. (reserved: {key.reserved_balance})",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
@@ -325,8 +324,7 @@ async def pay_for_request(key: ApiKey, session: AsyncSession, body: dict) -> int
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.balance) >= cost_per_request)
|
||||
.values(
|
||||
balance=col(ApiKey.balance) - cost_per_request,
|
||||
total_spent=col(ApiKey.total_spent) + cost_per_request,
|
||||
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
||||
total_requests=col(ApiKey.total_requests) + 1,
|
||||
)
|
||||
)
|
||||
@@ -365,7 +363,6 @@ async def pay_for_request(key: ApiKey, session: AsyncSession, body: dict) -> int
|
||||
"new_balance": key.balance,
|
||||
"total_spent": key.total_spent,
|
||||
"total_requests": key.total_requests,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -379,8 +376,7 @@ async def revert_pay_for_request(
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
balance=col(ApiKey.balance) + cost_per_request,
|
||||
total_spent=col(ApiKey.total_spent) - cost_per_request,
|
||||
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||
total_requests=col(ApiKey.total_requests) - 1,
|
||||
)
|
||||
)
|
||||
@@ -388,6 +384,14 @@ async def revert_pay_for_request(
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to revert payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"cost_to_revert": cost_per_request,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
@@ -438,6 +442,7 @@ async def adjust_payment_for_tokens(
|
||||
# If token-based pricing is enabled and base cost is 0, use token-based cost
|
||||
# Otherwise, token cost is additional to the base cost
|
||||
cost_difference = cost.total_msats - deducted_max_cost
|
||||
total_cost_msats: int = math.ceil(cost.total_msats)
|
||||
|
||||
logger.info(
|
||||
"Calculated token-based cost",
|
||||
@@ -460,6 +465,7 @@ async def adjust_payment_for_tokens(
|
||||
await session.commit()
|
||||
return cost.dict()
|
||||
|
||||
# this should never happen why do we handle this???
|
||||
if cost_difference > 0:
|
||||
# Need to charge more
|
||||
logger.info(
|
||||
@@ -473,6 +479,7 @@ async def adjust_payment_for_tokens(
|
||||
},
|
||||
)
|
||||
|
||||
# this should never happen why do we handle this???
|
||||
if key.balance < cost_difference:
|
||||
logger.warning(
|
||||
"Insufficient balance for token-based pricing adjustment",
|
||||
@@ -486,6 +493,7 @@ async def adjust_payment_for_tokens(
|
||||
)
|
||||
await session.commit()
|
||||
else:
|
||||
# this should never happen why do we handle this???
|
||||
charge_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
@@ -538,13 +546,30 @@ async def adjust_payment_for_tokens(
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
balance=col(ApiKey.balance) + refund,
|
||||
total_spent=col(ApiKey.total_spent) - refund,
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(ApiKey.balance) - total_cost_msats,
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
)
|
||||
)
|
||||
await session.exec(refund_stmt) # type: ignore[call-overload]
|
||||
result = await session.exec(refund_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
cost.total_msats = deducted_max_cost - refund
|
||||
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to finalize payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
"total_cost": total_cost_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
# Still return the cost data even if we couldn't properly finalize
|
||||
# The reservation was already made, so the user has paid
|
||||
|
||||
cost.total_msats = total_cost_msats
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
|
||||
+35
-1
@@ -1,6 +1,7 @@
|
||||
from typing import Annotated, NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .auth import validate_bearer_key
|
||||
from .core.db import ApiKey, AsyncSession, get_session
|
||||
@@ -32,6 +33,29 @@ async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# TODO: Implement POST /v1/wallet/create endpoint
|
||||
# This endpoint should accept:
|
||||
# - cashu_token (required): The eCash token to deposit
|
||||
# - refund_lnurl (optional): LNURL for refunds (instead of refund_address in validate_bearer_key)
|
||||
# - refund_expiry (optional): Expiry timestamp for the key (maps to key_expiry_time in validate_bearer_key)
|
||||
# The endpoint should:
|
||||
# 1. Create a new wallet/API key from the cashu_token
|
||||
# 2. Store refund_lnurl and refund_expiry in the database
|
||||
# 3. Return the API key (rstr_...) and balance
|
||||
# Note: validate_bearer_key already supports refund_address and key_expiry_time params
|
||||
|
||||
|
||||
@router.get("/create")
|
||||
async def create_balance(
|
||||
initial_balance_token: str, session: AsyncSession = Depends(get_session)
|
||||
) -> dict:
|
||||
key = await validate_bearer_key(initial_balance_token, session)
|
||||
return {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": key.balance,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/info")
|
||||
async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
return {
|
||||
@@ -40,12 +64,22 @@ async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
}
|
||||
|
||||
|
||||
class TopupRequest(BaseModel):
|
||||
cashu_token: str
|
||||
|
||||
|
||||
@router.post("/topup")
|
||||
async def topup_wallet_endpoint(
|
||||
cashu_token: str,
|
||||
cashu_token: str | None = None,
|
||||
topup_request: TopupRequest | None = None,
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, int]:
|
||||
if topup_request is not None:
|
||||
cashu_token = topup_request.cashu_token
|
||||
if cashu_token is None:
|
||||
raise HTTPException(status_code=400, detail="A cashu_token is required.")
|
||||
|
||||
cashu_token = cashu_token.replace("\n", "").replace("\r", "").replace("\t", "")
|
||||
if len(cashu_token) < 10 or "cashu" not in cashu_token:
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
|
||||
@@ -667,7 +667,7 @@ async def withdraw(
|
||||
wallet = await get_wallet(
|
||||
withdraw_request.mint_url or TRUSTED_MINTS[0], withdraw_request.unit
|
||||
)
|
||||
proofs = await get_proofs_per_mint_and_unit(
|
||||
proofs = get_proofs_per_mint_and_unit(
|
||||
wallet,
|
||||
withdraw_request.mint_url or TRUSTED_MINTS[0],
|
||||
withdraw_request.unit,
|
||||
|
||||
@@ -23,6 +23,9 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
||||
|
||||
hashed_key: str = Field(primary_key=True)
|
||||
balance: int = Field(default=0, description="Balance in millisatoshis (msats)")
|
||||
reserved_balance: int = Field(
|
||||
default=0, description="Reserved balance in millisatoshis (msats)"
|
||||
)
|
||||
refund_address: str | None = Field(
|
||||
default=None,
|
||||
description="Lightning address to refund remaining balance after key expires",
|
||||
@@ -44,6 +47,10 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
||||
description="Currency of the cashu-token",
|
||||
)
|
||||
|
||||
@property
|
||||
def total_balance(self) -> int:
|
||||
return self.balance - self.reserved_balance
|
||||
|
||||
|
||||
async def balances_for_mint_and_unit(
|
||||
db_session: AsyncSession, mint_url: str, unit: str
|
||||
|
||||
@@ -24,7 +24,7 @@ from .middleware import LoggingMiddleware
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.1.1b"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
+39
-14
@@ -9,6 +9,10 @@ import httpx
|
||||
import websockets
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
providers_router = APIRouter(prefix="/v1/providers")
|
||||
|
||||
|
||||
@@ -44,7 +48,7 @@ async def query_nostr_relay_for_providers(
|
||||
|
||||
try:
|
||||
async with websockets.connect(relay_url, timeout=timeout) as websocket:
|
||||
print("Connected to relay, searching for provider announcement events")
|
||||
logger.debug("Connected to relay, searching for kind 31338 events")
|
||||
await websocket.send(req_message)
|
||||
|
||||
while True:
|
||||
@@ -54,27 +58,27 @@ async def query_nostr_relay_for_providers(
|
||||
|
||||
if data[0] == "EVENT" and data[1] == sub_id:
|
||||
event = data[2]
|
||||
print(f"Found provider announcement: {event['id']}")
|
||||
logger.debug(f"Found provider announcement: {event['id']}")
|
||||
events.append(event)
|
||||
elif data[0] == "EOSE" and data[1] == sub_id:
|
||||
print("Received EOSE message")
|
||||
logger.debug("Received EOSE message")
|
||||
break
|
||||
elif data[0] == "NOTICE":
|
||||
print(f"Relay notice: {data[1]}")
|
||||
logger.warning(f"Relay notice: {data[1]}")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
print("Timeout waiting for message")
|
||||
logger.debug("Timeout waiting for message")
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
print("Failed to decode message as JSON")
|
||||
logger.warning("Failed to decode message as JSON")
|
||||
continue
|
||||
|
||||
await websocket.send(json.dumps(["CLOSE", sub_id]))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Query failed: {e}")
|
||||
logger.error(f"Query failed: {e}")
|
||||
|
||||
print(f"Query complete. Found {len(events)} provider announcements")
|
||||
logger.info(f"Query complete. Found {len(events)} provider announcements")
|
||||
return events
|
||||
|
||||
|
||||
@@ -92,6 +96,25 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
d_tag = None
|
||||
endpoint_urls = []
|
||||
provider_name = None
|
||||
endpoint_url = None
|
||||
|
||||
for tag in tags:
|
||||
if len(tag) >= 2:
|
||||
if tag[0] == "endpoint":
|
||||
endpoint_url = tag[1]
|
||||
elif tag[0] == "name":
|
||||
provider_name = tag[1]
|
||||
elif tag[0] == "d":
|
||||
d_tag = tag[1]
|
||||
|
||||
# Validate required fields
|
||||
if not endpoint_url or not provider_name or not d_tag:
|
||||
logger.warning(
|
||||
f"Invalid provider announcement - missing required tags: {event['id']}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Extract optional tags
|
||||
description = None
|
||||
contact = None
|
||||
pricing_url = None
|
||||
@@ -160,7 +183,9 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}")
|
||||
logger.error(
|
||||
f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -244,7 +269,7 @@ async def get_providers(
|
||||
|
||||
# Query multiple relays for provider announcements
|
||||
for relay_url in discovery_relays:
|
||||
print(f"\nQuerying relay for providers: {relay_url}")
|
||||
logger.info(f"Querying relay for providers: {relay_url}")
|
||||
try:
|
||||
events = await query_nostr_relay_for_providers(
|
||||
relay_url=relay_url,
|
||||
@@ -258,13 +283,13 @@ async def get_providers(
|
||||
event_ids.add(event["id"])
|
||||
all_events.append(event)
|
||||
|
||||
print(f"Got {len(events)} provider announcements from {relay_url}")
|
||||
logger.info(f"Got {len(events)} provider announcements from {relay_url}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to query {relay_url}: {e}")
|
||||
logger.error(f"Failed to query {relay_url}: {e}")
|
||||
continue
|
||||
|
||||
print(f"Found {len(all_events)} total unique provider announcements")
|
||||
logger.info(f"Found {len(all_events)} total unique provider announcements")
|
||||
|
||||
# Parse provider announcements according to NIP-91
|
||||
providers = []
|
||||
@@ -273,7 +298,7 @@ async def get_providers(
|
||||
if parsed_provider:
|
||||
providers.append(parsed_provider)
|
||||
|
||||
print(f"Parsed {len(providers)} valid provider announcements")
|
||||
logger.info(f"Parsed {len(providers)} valid provider announcements")
|
||||
|
||||
# Check provider health if requested
|
||||
healthy_providers: list[dict[str, Any]] = []
|
||||
|
||||
@@ -19,30 +19,6 @@ if not UPSTREAM_BASE_URL:
|
||||
raise ValueError("Please set the UPSTREAM_BASE_URL environment variable")
|
||||
|
||||
|
||||
def get_cost_per_request(model: str | None = None) -> int:
|
||||
"""Get the cost per request for a given model."""
|
||||
logger.debug(
|
||||
"Calculating cost per request",
|
||||
extra={
|
||||
"model": model,
|
||||
"model_based_pricing": MODEL_BASED_PRICING,
|
||||
"has_models": bool(MODELS),
|
||||
},
|
||||
)
|
||||
|
||||
if MODEL_BASED_PRICING and MODELS and model:
|
||||
cost = get_max_cost_for_model(model=model)
|
||||
logger.debug(
|
||||
"Using model-based cost", extra={"model": model, "cost_msats": cost}
|
||||
)
|
||||
return cost
|
||||
|
||||
logger.debug(
|
||||
"Using default cost per request", extra={"cost_msats": COST_PER_REQUEST}
|
||||
)
|
||||
return COST_PER_REQUEST
|
||||
|
||||
|
||||
def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> None:
|
||||
if x_cashu := headers.get("x-cashu", None):
|
||||
cashu_token = x_cashu
|
||||
@@ -111,7 +87,7 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N
|
||||
)
|
||||
|
||||
|
||||
def get_max_cost_for_model(model: str) -> int:
|
||||
def get_max_cost_for_model(model: str, tolerance_percentage: int = 1) -> int:
|
||||
"""Get the maximum cost for a specific model."""
|
||||
logger.debug(
|
||||
"Getting max cost for model",
|
||||
@@ -142,7 +118,7 @@ def get_max_cost_for_model(model: str) -> int:
|
||||
|
||||
for m in MODELS:
|
||||
if m.id == model:
|
||||
max_cost = m.sats_pricing.max_cost * 1000 # type: ignore
|
||||
max_cost = m.sats_pricing.max_cost * 1000 * (1 - tolerance_percentage / 100) # type: ignore
|
||||
logger.debug(
|
||||
"Found model-specific max cost",
|
||||
extra={"model": model, "max_cost_msats": max_cost},
|
||||
|
||||
@@ -278,18 +278,13 @@ async def raw_send_to_lnurl(
|
||||
estimated_fees_msat = estimated_fees_sat * 1000
|
||||
final_amount = amount_msat - estimated_fees_msat
|
||||
|
||||
print(f"Final amount: {final_amount} {unit}")
|
||||
print(f"Estimated fees: {estimated_fees_msat} msat")
|
||||
print(f"Amount before fees: {amount_msat} {unit}")
|
||||
bolt11_invoice, _ = await get_lnurl_invoice(
|
||||
lnurl_data["callback_url"], final_amount
|
||||
)
|
||||
print(f"Bolt11 invoice: {bolt11_invoice}")
|
||||
|
||||
melt_quote_resp = await wallet.melt_quote(
|
||||
invoice=bolt11_invoice, amount_msat=final_amount
|
||||
)
|
||||
print(melt_quote_resp)
|
||||
_ = await wallet.melt(
|
||||
proofs=proofs,
|
||||
invoice=bolt11_invoice,
|
||||
|
||||
@@ -87,7 +87,7 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
|
||||
|
||||
return models_data
|
||||
except Exception as e:
|
||||
print(f"Error fetching models from OpenRouter API: {e}")
|
||||
logger.error(f"Error fetching models from OpenRouter API: {e}")
|
||||
return []
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ def load_models() -> list[Model]:
|
||||
data = json.load(f)
|
||||
return [Model(**model) for model in data.get("models", [])]
|
||||
except Exception as e:
|
||||
print(f"Error loading models from {models_path}: {e}")
|
||||
logger.error(f"Error loading models from {models_path}: {e}")
|
||||
# Fall through to auto-generation
|
||||
|
||||
# Auto-generate models from OpenRouter API
|
||||
@@ -168,7 +168,7 @@ async def update_sats_pricing() -> None:
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
print("Error updating sats pricing: ", e)
|
||||
logger.error(f"Error updating sats pricing: {e}")
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
|
||||
+36
-22
@@ -9,18 +9,13 @@ from fastapi.responses import Response, StreamingResponse
|
||||
from ..core import get_logger
|
||||
from ..wallet import recieve_token, send_token
|
||||
from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost
|
||||
from .helpers import (
|
||||
UPSTREAM_BASE_URL,
|
||||
create_error_response,
|
||||
get_max_cost_for_model,
|
||||
prepare_upstream_headers,
|
||||
)
|
||||
from .helpers import UPSTREAM_BASE_URL, create_error_response, prepare_upstream_headers
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def x_cashu_handler(
|
||||
request: Request, x_cashu_token: str, path: str
|
||||
request: Request, x_cashu_token: str, path: str, max_cost_for_model: int
|
||||
) -> Response | StreamingResponse:
|
||||
"""Handle X-Cashu token payment requests."""
|
||||
logger.info(
|
||||
@@ -44,7 +39,9 @@ async def x_cashu_handler(
|
||||
extra={"amount": amount, "unit": unit, "path": path, "mint": mint},
|
||||
)
|
||||
|
||||
return await forward_to_upstream(request, path, headers, amount, unit)
|
||||
return await forward_to_upstream(
|
||||
request, path, headers, amount, unit, max_cost_for_model
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
logger.error(
|
||||
@@ -96,7 +93,12 @@ async def x_cashu_handler(
|
||||
|
||||
|
||||
async def forward_to_upstream(
|
||||
request: Request, path: str, headers: dict, amount: int, unit: str
|
||||
request: Request,
|
||||
path: str,
|
||||
headers: dict,
|
||||
amount: int,
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
) -> Response | StreamingResponse:
|
||||
"""Forward request to upstream and handle the response."""
|
||||
if path.startswith("v1/"):
|
||||
@@ -188,7 +190,9 @@ async def forward_to_upstream(
|
||||
extra={"path": path, "amount": amount, "unit": unit},
|
||||
)
|
||||
|
||||
result = await handle_x_cashu_chat_completion(response, amount, unit)
|
||||
result = await handle_x_cashu_chat_completion(
|
||||
response, amount, unit, max_cost_for_model
|
||||
)
|
||||
background_tasks = BackgroundTasks()
|
||||
background_tasks.add_task(response.aclose)
|
||||
result.background = background_tasks
|
||||
@@ -232,7 +236,7 @@ async def forward_to_upstream(
|
||||
|
||||
|
||||
async def handle_x_cashu_chat_completion(
|
||||
response: httpx.Response, amount: int, unit: str
|
||||
response: httpx.Response, amount: int, unit: str, max_cost_for_model: int
|
||||
) -> StreamingResponse | Response:
|
||||
"""Handle both streaming and non-streaming chat completion responses with token-based pricing."""
|
||||
logger.debug(
|
||||
@@ -256,10 +260,12 @@ async def handle_x_cashu_chat_completion(
|
||||
)
|
||||
|
||||
if is_streaming:
|
||||
return await handle_streaming_response(content_str, response, amount, unit)
|
||||
return await handle_streaming_response(
|
||||
content_str, response, amount, unit, max_cost_for_model
|
||||
)
|
||||
else:
|
||||
return await handle_non_streaming_response(
|
||||
content_str, response, amount, unit
|
||||
content_str, response, amount, unit, max_cost_for_model
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -281,7 +287,11 @@ async def handle_x_cashu_chat_completion(
|
||||
|
||||
|
||||
async def handle_streaming_response(
|
||||
content_str: str, response: httpx.Response, amount: int, unit: str
|
||||
content_str: str,
|
||||
response: httpx.Response,
|
||||
amount: int,
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
) -> StreamingResponse:
|
||||
"""Handle Server-Sent Events (SSE) streaming response."""
|
||||
logger.debug(
|
||||
@@ -335,7 +345,7 @@ async def handle_streaming_response(
|
||||
|
||||
response_data = {"usage": usage_data, "model": model}
|
||||
try:
|
||||
cost_data = await get_cost(response_data)
|
||||
cost_data = await get_cost(response_data, max_cost_for_model)
|
||||
if cost_data:
|
||||
if unit == "msat":
|
||||
refund_amount = amount - cost_data.total_msats
|
||||
@@ -403,7 +413,11 @@ async def handle_streaming_response(
|
||||
|
||||
|
||||
async def handle_non_streaming_response(
|
||||
content_str: str, response: httpx.Response, amount: int, unit: str
|
||||
content_str: str,
|
||||
response: httpx.Response,
|
||||
amount: int,
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
) -> Response:
|
||||
"""Handle regular JSON response."""
|
||||
logger.debug(
|
||||
@@ -414,7 +428,7 @@ async def handle_non_streaming_response(
|
||||
try:
|
||||
response_json = json.loads(content_str)
|
||||
|
||||
cost_data = await get_cost(response_json)
|
||||
cost_data = await get_cost(response_json, max_cost_for_model)
|
||||
|
||||
if not cost_data:
|
||||
logger.error(
|
||||
@@ -520,21 +534,21 @@ async def handle_non_streaming_response(
|
||||
)
|
||||
|
||||
|
||||
async def get_cost(response_data: dict) -> MaxCostData | CostData | None:
|
||||
async def get_cost(
|
||||
response_data: dict, max_cost_for_model: int
|
||||
) -> MaxCostData | CostData | None:
|
||||
"""
|
||||
Adjusts the payment based on token usage in the response.
|
||||
This is called after the initial payment and the upstream request is complete.
|
||||
Returns cost data to be included in the response.
|
||||
"""
|
||||
model = response_data.get("model", "unknown")
|
||||
model = response_data.get("model", None)
|
||||
logger.debug(
|
||||
"Calculating cost for response",
|
||||
extra={"model": model, "has_usage": "usage" in response_data},
|
||||
)
|
||||
|
||||
max_cost = get_max_cost_for_model(model=model)
|
||||
|
||||
match calculate_cost(response_data, max_cost):
|
||||
match calculate_cost(response_data, max_cost_for_model):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
"Using max cost pricing",
|
||||
|
||||
+21
-9
@@ -19,7 +19,7 @@ from .payment.helpers import (
|
||||
UPSTREAM_BASE_URL,
|
||||
check_token_balance,
|
||||
create_error_response,
|
||||
get_cost_per_request,
|
||||
get_max_cost_for_model,
|
||||
prepare_upstream_headers,
|
||||
)
|
||||
from .payment.x_cashu import x_cashu_handler
|
||||
@@ -501,9 +501,8 @@ async def proxy(
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
max_cost_for_model = get_cost_per_request(
|
||||
model=request_body_dict.get("model", None)
|
||||
)
|
||||
model = request_body_dict.get("model", "unknown")
|
||||
max_cost_for_model = get_max_cost_for_model(model=model)
|
||||
check_token_balance(headers, request_body_dict, max_cost_for_model)
|
||||
|
||||
# Handle authentication
|
||||
@@ -515,7 +514,7 @@ async def proxy(
|
||||
"token_preview": x_cashu[:20] + "..." if len(x_cashu) > 20 else x_cashu,
|
||||
},
|
||||
)
|
||||
return await x_cashu_handler(request, x_cashu, path)
|
||||
return await x_cashu_handler(request, x_cashu, path, max_cost_for_model)
|
||||
|
||||
elif auth := headers.get("authorization", None):
|
||||
logger.debug(
|
||||
@@ -540,11 +539,10 @@ async def proxy(
|
||||
)
|
||||
|
||||
logger.debug("Processing unauthenticated GET request", extra={"path": path})
|
||||
# Prepare headers for upstream
|
||||
# TODO: why is this needed? can we remove it?
|
||||
headers = prepare_upstream_headers(dict(request.headers))
|
||||
return await forward_get_to_upstream(request, path, headers)
|
||||
|
||||
cost_per_request = 0
|
||||
# Only pay for request if we have request body data (for completions endpoints)
|
||||
if request_body_dict:
|
||||
logger.info(
|
||||
@@ -558,7 +556,7 @@ async def proxy(
|
||||
)
|
||||
|
||||
try:
|
||||
await pay_for_request(key, session, request_body_dict)
|
||||
await pay_for_request(key, max_cost_for_model, session)
|
||||
logger.info(
|
||||
"Payment processed successfully",
|
||||
extra={
|
||||
@@ -589,7 +587,7 @@ async def proxy(
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
await revert_pay_for_request(key, session, cost_per_request)
|
||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||
logger.warning(
|
||||
"Upstream request failed, revert payment",
|
||||
extra={
|
||||
@@ -597,8 +595,22 @@ async def proxy(
|
||||
"path": path,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"key_balance": key.balance,
|
||||
"max_cost_for_model": max_cost_for_model,
|
||||
"upstream_headers": response.headers
|
||||
if hasattr(response, "headers")
|
||||
else None,
|
||||
"upstream_response": response.body
|
||||
if hasattr(response, "body")
|
||||
else None,
|
||||
},
|
||||
)
|
||||
request_id = (
|
||||
request.state.request_id if hasattr(request.state, "request_id") else None
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Upstream request failed, please contact support with request id: {request_id}",
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
+49
-63
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import math
|
||||
import os
|
||||
from typing import TypedDict
|
||||
|
||||
@@ -19,13 +20,7 @@ RECEIVE_LN_ADDRESS = os.environ.get("RECEIVE_LN_ADDRESS", "")
|
||||
|
||||
|
||||
async def get_balance(unit: str) -> int:
|
||||
wallet = await Wallet.with_db(
|
||||
PRIMARY_MINT_URL,
|
||||
db=".wallet",
|
||||
load_all_keysets=True,
|
||||
unit=unit,
|
||||
)
|
||||
await wallet.load_proofs()
|
||||
wallet = await get_wallet(PRIMARY_MINT_URL, unit)
|
||||
return wallet.available_balance.amount
|
||||
|
||||
|
||||
@@ -36,13 +31,8 @@ async def recieve_token(
|
||||
if len(token_obj.keysets) > 1:
|
||||
raise ValueError("Multiple keysets per token currently not supported")
|
||||
|
||||
wallet = await Wallet.with_db(
|
||||
token_obj.mint,
|
||||
db=".wallet",
|
||||
load_all_keysets=True,
|
||||
unit=token_obj.unit,
|
||||
)
|
||||
await wallet.load_mint(token_obj.keysets[0])
|
||||
wallet = await get_wallet(token_obj.mint, token_obj.unit, load=False)
|
||||
wallet.keyset_id = token_obj.keysets[0]
|
||||
|
||||
if token_obj.mint not in TRUSTED_MINTS:
|
||||
return await swap_to_primary_mint(token_obj, wallet)
|
||||
@@ -54,14 +44,8 @@ async def recieve_token(
|
||||
|
||||
async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]:
|
||||
"""Internal send function - returns amount and serialized token"""
|
||||
wallet: Wallet = await Wallet.with_db(
|
||||
mint_url or PRIMARY_MINT_URL, db=".wallet", load_all_keysets=True, unit=unit
|
||||
)
|
||||
await wallet.load_mint()
|
||||
await wallet.load_proofs()
|
||||
proofs = await get_proofs_per_mint_and_unit(
|
||||
wallet, mint_url or PRIMARY_MINT_URL, unit
|
||||
)
|
||||
wallet: Wallet = await get_wallet(mint_url or PRIMARY_MINT_URL, unit)
|
||||
proofs = get_proofs_per_mint_and_unit(wallet, mint_url or PRIMARY_MINT_URL, unit)
|
||||
|
||||
send_proofs, _ = await wallet.select_to_send(
|
||||
proofs, amount, set_reserved=True, include_fees=False
|
||||
@@ -88,20 +72,23 @@ async def swap_to_primary_mint(
|
||||
"unit": token_obj.unit,
|
||||
},
|
||||
)
|
||||
# Ensure amount is an integer
|
||||
if not isinstance(token_obj.amount, int):
|
||||
token_amount = int(token_obj.amount)
|
||||
else:
|
||||
token_amount = token_obj.amount
|
||||
|
||||
if token_obj.unit == "sat":
|
||||
amount_msat = token_obj.amount * 1000
|
||||
amount_msat = token_amount * 1000
|
||||
elif token_obj.unit == "msat":
|
||||
amount_msat = token_obj.amount
|
||||
amount_msat = token_amount
|
||||
else:
|
||||
raise ValueError("Invalid unit")
|
||||
estimated_fee_sat = int(max(amount_msat // 1000 * 0.01, 2))
|
||||
estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2))
|
||||
amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000
|
||||
primary_wallet = await Wallet.with_db(
|
||||
PRIMARY_MINT_URL, db=".wallet", load_all_keysets=True, unit="sat"
|
||||
)
|
||||
await primary_wallet.load_mint()
|
||||
primary_wallet = await get_wallet(PRIMARY_MINT_URL, "sat")
|
||||
|
||||
minted_amount = amount_msat_after_fee // 1000
|
||||
minted_amount = int(amount_msat_after_fee // 1000)
|
||||
mint_quote = await primary_wallet.request_mint(minted_amount)
|
||||
|
||||
melt_quote = await token_wallet.melt_quote(mint_quote.request)
|
||||
@@ -113,7 +100,7 @@ async def swap_to_primary_mint(
|
||||
)
|
||||
_ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote)
|
||||
|
||||
return minted_amount, "sat", PRIMARY_MINT_URL
|
||||
return int(minted_amount), "sat", PRIMARY_MINT_URL
|
||||
|
||||
|
||||
async def credit_balance(
|
||||
@@ -162,16 +149,24 @@ async def credit_balance(
|
||||
raise
|
||||
|
||||
|
||||
async def get_wallet(mint_url: str, unit: str = "sat") -> Wallet:
|
||||
wallet = await Wallet.with_db(
|
||||
mint_url, db=".wallet", load_all_keysets=True, unit=unit
|
||||
)
|
||||
await wallet.load_mint()
|
||||
await wallet.load_proofs(reload=True)
|
||||
return wallet
|
||||
_wallets: dict[str, Wallet] = {}
|
||||
|
||||
|
||||
async def get_proofs_per_mint_and_unit(
|
||||
async def get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Wallet:
|
||||
global _wallets
|
||||
id = f"{mint_url}_{unit}"
|
||||
if id not in _wallets:
|
||||
_wallets[id] = await Wallet.with_db(
|
||||
mint_url, db=".wallet", load_all_keysets=True, unit=unit
|
||||
)
|
||||
|
||||
if load:
|
||||
await _wallets[id].load_mint()
|
||||
await _wallets[id].load_proofs(reload=True)
|
||||
return _wallets[id]
|
||||
|
||||
|
||||
def get_proofs_per_mint_and_unit(
|
||||
wallet: Wallet, mint_url: str, unit: str, not_reserved: bool = False
|
||||
) -> list[Proof]:
|
||||
valid_keyset_ids = [
|
||||
@@ -188,14 +183,16 @@ async def get_proofs_per_mint_and_unit(
|
||||
async def slow_filter_spend_proofs(proofs: list[Proof], wallet: Wallet) -> list[Proof]:
|
||||
if not proofs:
|
||||
return []
|
||||
proof_states = await wallet.check_proof_state(proofs)
|
||||
_proofs = []
|
||||
_spent_proofs = []
|
||||
for proof, state in zip(proofs, proof_states.states):
|
||||
if str(state.state) != "spent":
|
||||
_proofs.append(proof)
|
||||
else:
|
||||
_spent_proofs.append(proof)
|
||||
for i in range(0, len(proofs), 1000):
|
||||
pb = proofs[i : i + 1000]
|
||||
proof_states = await wallet.check_proof_state(pb)
|
||||
for proof, state in zip(pb, proof_states.states):
|
||||
if str(state.state) != "spent":
|
||||
_proofs.append(proof)
|
||||
else:
|
||||
_spent_proofs.append(proof)
|
||||
await wallet.set_reserved_for_send(_spent_proofs, reserved=True)
|
||||
return _proofs
|
||||
|
||||
@@ -229,7 +226,7 @@ async def fetch_all_balances(
|
||||
) -> BalanceDetail:
|
||||
try:
|
||||
wallet = await get_wallet(mint_url, unit)
|
||||
proofs = await get_proofs_per_mint_and_unit(
|
||||
proofs = get_proofs_per_mint_and_unit(
|
||||
wallet, mint_url, unit, not_reserved=True
|
||||
)
|
||||
proofs = await slow_filter_spend_proofs(proofs, wallet)
|
||||
@@ -306,13 +303,13 @@ async def periodic_payout() -> None:
|
||||
logger.error("RECEIVE_LN_ADDRESS is not set, skipping payout")
|
||||
return
|
||||
while True:
|
||||
await asyncio.sleep(60)
|
||||
await asyncio.sleep(60 * 5)
|
||||
try:
|
||||
async with db.create_session() as session:
|
||||
for mint_url in TRUSTED_MINTS:
|
||||
for unit in ["sat", "msat"]:
|
||||
wallet = await get_wallet(mint_url, unit)
|
||||
proofs = await get_proofs_per_mint_and_unit(
|
||||
proofs = get_proofs_per_mint_and_unit(
|
||||
wallet, mint_url, unit, not_reserved=True
|
||||
)
|
||||
proofs = await slow_filter_spend_proofs(proofs, wallet)
|
||||
@@ -323,32 +320,21 @@ async def periodic_payout() -> None:
|
||||
user_balance = user_balance // 1000
|
||||
proofs_balance = sum(proof.amount for proof in proofs)
|
||||
available_balance = proofs_balance - user_balance
|
||||
print(f"Balance: {proofs_balance} {unit}")
|
||||
print(f"User balance: {user_balance} {unit}")
|
||||
print(f"Available balance: {available_balance} {unit}")
|
||||
min_amount = 210 if unit == "sat" else 210000
|
||||
if proofs_balance > min_amount:
|
||||
if available_balance > min_amount:
|
||||
amount_received = await raw_send_to_lnurl(
|
||||
wallet, proofs, RECEIVE_LN_ADDRESS, unit
|
||||
)
|
||||
print(f"Amount received: {amount_received}")
|
||||
logger.info(
|
||||
"Payout sent successfully",
|
||||
extra={
|
||||
"mint_url": mint_url,
|
||||
"unit": unit,
|
||||
"balance": proofs_balance,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Not enough balance to send payout",
|
||||
extra={
|
||||
"mint_url": mint_url,
|
||||
"unit": unit,
|
||||
"balance": proofs_balance,
|
||||
"balance": available_balance,
|
||||
"amount_received": amount_received,
|
||||
},
|
||||
)
|
||||
|
||||
await asyncio.sleep(5)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
||||
@@ -8,8 +8,9 @@ import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.logging import get_logger
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Comprehensive error handling and edge case tests"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient, ConnectError
|
||||
from httpx import ASGITransport, AsyncClient, ConnectError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
@@ -465,7 +466,7 @@ class TestRecoveryScenarios:
|
||||
# Simulate operations that might be interrupted
|
||||
try:
|
||||
# Start a transaction
|
||||
api_key.balance -= 1000
|
||||
api_key.reserved_balance += 1000
|
||||
api_key.total_requests += 1
|
||||
# Don't commit - simulate crash
|
||||
raise Exception("Simulated database crash")
|
||||
@@ -616,30 +617,56 @@ class TestEdgeCaseCombinations:
|
||||
@pytest.mark.asyncio
|
||||
async def test_rapid_balance_exhaustion(
|
||||
self,
|
||||
authenticated_client: AsyncClient,
|
||||
integration_app: Any,
|
||||
integration_session: AsyncSession,
|
||||
testmint_wallet: Any,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test behavior when balance is rapidly exhausted"""
|
||||
# Set a low balance
|
||||
api_key_header = authenticated_client.headers["Authorization"].replace(
|
||||
"Bearer ", ""
|
||||
)
|
||||
api_key_hash = (
|
||||
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
|
||||
)
|
||||
"""Test behavior when balance is rapidly exhausted by concurrent requests.
|
||||
|
||||
# Set balance to just 1000 msats (1 sat)
|
||||
from sqlalchemy import update
|
||||
This test creates an API key with insufficient balance (500 msats) for even
|
||||
a single request (which costs 1000 msats). It then makes 5 concurrent requests
|
||||
to verify that all requests fail with 402 Payment Required errors.
|
||||
|
||||
await integration_session.execute(
|
||||
update(ApiKey).where(ApiKey.hashed_key == api_key_hash).values(balance=1000) # type: ignore[arg-type]
|
||||
Note: The test disables MODEL_BASED_PRICING to avoid model lookup errors
|
||||
since the test environment doesn't have models configured.
|
||||
"""
|
||||
# Disable MODEL_BASED_PRICING for this test to avoid model lookup issues
|
||||
monkeypatch.setattr(
|
||||
"routstr.payment.cost_caculation.MODEL_BASED_PRICING", False
|
||||
)
|
||||
monkeypatch.setattr("routstr.payment.helpers.MODEL_BASED_PRICING", False)
|
||||
|
||||
# Create a new API key with very low balance
|
||||
# Generate a unique API key
|
||||
test_key = f"sk-test-low-balance-{hashlib.sha256(str(time.time()).encode()).hexdigest()[:8]}"
|
||||
api_key_hash = test_key[3:] # Remove sk- prefix
|
||||
|
||||
# Create the API key with only 500 msats (less than one request cost)
|
||||
new_key = ApiKey(
|
||||
hashed_key=api_key_hash,
|
||||
balance=500, # Less than COST_PER_REQUEST (1000 msats)
|
||||
reserved_balance=0,
|
||||
total_spent=0,
|
||||
total_requests=0,
|
||||
)
|
||||
integration_session.add(new_key)
|
||||
await integration_session.commit()
|
||||
|
||||
# Verify the key was created
|
||||
await integration_session.refresh(new_key)
|
||||
|
||||
# Create a client with this low-balance key
|
||||
low_balance_client = AsyncClient(
|
||||
transport=ASGITransport(app=integration_app), # type: ignore
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {test_key}"},
|
||||
)
|
||||
|
||||
# Make multiple concurrent requests that would exhaust balance
|
||||
tasks = []
|
||||
for _ in range(5):
|
||||
task = authenticated_client.post(
|
||||
task = low_balance_client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "gpt-3.5-turbo",
|
||||
@@ -665,3 +692,6 @@ class TestEdgeCaseCombinations:
|
||||
result = await integration_session.execute(stmt)
|
||||
final_key = result.scalar_one()
|
||||
assert final_key.balance >= 0
|
||||
|
||||
# Clean up the test client
|
||||
await low_balance_client.aclose()
|
||||
|
||||
@@ -355,27 +355,29 @@ async def test_proxy_post_model_specific_endpoints(
|
||||
},
|
||||
"response": {"object": "chat.completion", "model": "gpt-3.5-turbo"},
|
||||
},
|
||||
{
|
||||
"endpoint": "/v1/completions",
|
||||
"payload": {
|
||||
"model": "text-davinci-003",
|
||||
"prompt": "Hello world",
|
||||
"max_tokens": 50,
|
||||
},
|
||||
"response": {"object": "text_completion", "model": "text-davinci-003"},
|
||||
},
|
||||
{
|
||||
"endpoint": "/v1/embeddings",
|
||||
"payload": {
|
||||
"model": "text-embedding-ada-002",
|
||||
"input": "The quick brown fox",
|
||||
},
|
||||
"response": {
|
||||
"object": "list",
|
||||
"model": "text-embedding-ada-002",
|
||||
"data": [{"object": "embedding", "embedding": [0.1, 0.2, 0.3]}],
|
||||
},
|
||||
},
|
||||
# Coming soon - completions endpoint
|
||||
# {
|
||||
# "endpoint": "/v1/completions",
|
||||
# "payload": {
|
||||
# "model": "text-davinci-003",
|
||||
# "prompt": "Hello world",
|
||||
# "max_tokens": 50,
|
||||
# },
|
||||
# "response": {"object": "text_completion", "model": "text-davinci-003"},
|
||||
# },
|
||||
# Coming soon - embeddings endpoint
|
||||
# {
|
||||
# "endpoint": "/v1/embeddings",
|
||||
# "payload": {
|
||||
# "model": "text-embedding-ada-002",
|
||||
# "input": "The quick brown fox",
|
||||
# },
|
||||
# "response": {
|
||||
# "object": "list",
|
||||
# "model": "text-embedding-ada-002",
|
||||
# "data": [{"object": "embedding", "embedding": [0.1, 0.2, 0.3]}],
|
||||
# },
|
||||
# },
|
||||
]
|
||||
|
||||
for test_case in test_cases:
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Test to verify reserved balance never goes negative."""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.db import ApiKey, create_session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reserved_balance_never_negative(integration_client: AsyncClient) -> None:
|
||||
"""Test that reserved balance never goes negative under various conditions."""
|
||||
|
||||
# Create a test API key with limited balance
|
||||
async with create_session() as session:
|
||||
test_key = ApiKey(
|
||||
hashed_key="test_reserved_balance_key",
|
||||
balance=1000, # 1 sat
|
||||
reserved_balance=0,
|
||||
)
|
||||
session.add(test_key)
|
||||
await session.commit()
|
||||
|
||||
bearer_token = "sk-test_reserved_balance_key"
|
||||
headers = {"Authorization": f"Bearer {bearer_token}"}
|
||||
|
||||
# Test 1: Make a request that will fail upstream
|
||||
# This should reserve funds and then revert them
|
||||
await integration_client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers,
|
||||
json={
|
||||
"model": "invalid-model-that-will-fail",
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
},
|
||||
)
|
||||
|
||||
# Check reserved balance after failed request
|
||||
async with create_session() as session:
|
||||
key = await session.get(ApiKey, "test_reserved_balance_key")
|
||||
assert key is not None
|
||||
assert key.reserved_balance >= 0, (
|
||||
f"Reserved balance went negative: {key.reserved_balance}"
|
||||
)
|
||||
assert key.balance == 1000, (
|
||||
"Balance should remain unchanged after failed request"
|
||||
)
|
||||
|
||||
# Test 2: Simulate concurrent failed requests
|
||||
# This tests the race condition protection
|
||||
async def make_failing_request() -> None:
|
||||
try:
|
||||
await integration_client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers,
|
||||
json={
|
||||
"model": "invalid-model",
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass # Expected to fail
|
||||
|
||||
# Run multiple concurrent requests
|
||||
await asyncio.gather(*[make_failing_request() for _ in range(5)])
|
||||
|
||||
# Check final state
|
||||
async with create_session() as session:
|
||||
key = await session.get(ApiKey, "test_reserved_balance_key")
|
||||
assert key is not None
|
||||
assert key.reserved_balance >= 0, (
|
||||
f"Reserved balance went negative after concurrent requests: {key.reserved_balance}"
|
||||
)
|
||||
print(f"Final state - Balance: {key.balance}, Reserved: {key.reserved_balance}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reserved_balance_with_successful_requests(
|
||||
integration_client: AsyncClient,
|
||||
) -> None:
|
||||
"""Test reserved balance handling with successful requests."""
|
||||
|
||||
# Create a test API key with more balance
|
||||
async with create_session() as session:
|
||||
unique_key = f"test_successful_key_{uuid.uuid4().hex[:8]}"
|
||||
test_key = ApiKey(
|
||||
hashed_key=unique_key,
|
||||
balance=100000, # 100 sats
|
||||
reserved_balance=0,
|
||||
)
|
||||
session.add(test_key)
|
||||
await session.commit()
|
||||
|
||||
bearer_token = f"sk-{unique_key}"
|
||||
headers = {"Authorization": f"Bearer {bearer_token}"}
|
||||
|
||||
# Make a valid request (assuming you have a mock or test endpoint)
|
||||
# This test might need adjustment based on your test setup
|
||||
await integration_client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers,
|
||||
json={
|
||||
"model": "gpt-4o-mini", # Or whatever model is available in test
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"max_tokens": 10,
|
||||
},
|
||||
)
|
||||
|
||||
# Check that reserved balance was properly adjusted
|
||||
async with create_session() as session:
|
||||
key = await session.get(ApiKey, unique_key)
|
||||
assert key is not None
|
||||
assert key.reserved_balance >= 0, (
|
||||
f"Reserved balance went negative: {key.reserved_balance}"
|
||||
)
|
||||
# Check if the request was processed (might fail due to model pricing in test env)
|
||||
# The important part is that reserved_balance doesn't go negative
|
||||
if key.total_spent > 0:
|
||||
assert key.balance < 100000, (
|
||||
"Balance should decrease after successful request"
|
||||
)
|
||||
else:
|
||||
# Request failed, but reserved balance should still be non-negative
|
||||
assert key.balance == 100000, (
|
||||
"Balance should remain unchanged if request failed"
|
||||
)
|
||||
print(
|
||||
f"After successful request - Balance: {key.balance}, Reserved: {key.reserved_balance}, Spent: {key.total_spent}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insufficient_reserved_balance_for_revert(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test revert_pay_for_request behavior with insufficient reserved balance."""
|
||||
from routstr.auth import revert_pay_for_request
|
||||
|
||||
# Create key with zero reserved balance
|
||||
unique_key = f"test_revert_key_{uuid.uuid4().hex[:8]}"
|
||||
test_key = ApiKey(
|
||||
hashed_key=unique_key,
|
||||
balance=1000,
|
||||
reserved_balance=0,
|
||||
)
|
||||
integration_session.add(test_key)
|
||||
await integration_session.commit()
|
||||
|
||||
# Try to revert more than available
|
||||
# Note: Current implementation allows reserved_balance to go negative
|
||||
await revert_pay_for_request(test_key, integration_session, 100)
|
||||
|
||||
# Refresh to get updated values
|
||||
await integration_session.refresh(test_key)
|
||||
|
||||
# Current implementation allows negative reserved balance
|
||||
assert test_key.reserved_balance == -100, (
|
||||
f"Expected reserved_balance to be -100, got: {test_key.reserved_balance}"
|
||||
)
|
||||
assert test_key.total_requests == -1, (
|
||||
f"Expected total_requests to be -1, got: {test_key.total_requests}"
|
||||
)
|
||||
@@ -16,19 +16,31 @@ def test_get_max_cost_for_model_known() -> None:
|
||||
|
||||
with patch("routstr.payment.helpers.MODELS", [mock_model]):
|
||||
with patch("routstr.payment.helpers.MODEL_BASED_PRICING", True):
|
||||
cost = get_max_cost_for_model("gpt-4")
|
||||
cost = get_max_cost_for_model("gpt-4", tolerance_percentage=0)
|
||||
assert cost == 500000 # 500 sats * 1000 = msats
|
||||
|
||||
|
||||
def test_get_max_cost_for_model_unknown() -> None:
|
||||
with patch("routstr.payment.helpers.MODELS", []):
|
||||
with patch("routstr.payment.helpers.COST_PER_REQUEST", 100):
|
||||
cost = get_max_cost_for_model("unknown-model")
|
||||
cost = get_max_cost_for_model("unknown-model", tolerance_percentage=0)
|
||||
assert cost == 100
|
||||
|
||||
|
||||
def test_get_max_cost_for_model_disabled() -> None:
|
||||
with patch("routstr.payment.helpers.MODEL_BASED_PRICING", False):
|
||||
with patch("routstr.payment.helpers.COST_PER_REQUEST", 200):
|
||||
cost = get_max_cost_for_model("any-model")
|
||||
cost = get_max_cost_for_model("any-model", tolerance_percentage=0)
|
||||
assert cost == 200
|
||||
|
||||
|
||||
def test_get_max_cost_for_model_tolerance() -> None:
|
||||
mock_model = Mock()
|
||||
mock_model.id = "gpt-4"
|
||||
mock_model.sats_pricing = Mock()
|
||||
mock_model.sats_pricing.max_cost = 500
|
||||
|
||||
with patch("routstr.payment.helpers.MODELS", [mock_model]):
|
||||
with patch("routstr.payment.helpers.MODEL_BASED_PRICING", True):
|
||||
cost = get_max_cost_for_model("gpt-4", tolerance_percentage=10)
|
||||
assert cost == 450000 # 500 sats * 1000 * 0.9 = 450000
|
||||
|
||||
@@ -11,6 +11,7 @@ from routstr.wallet import credit_balance, get_balance, recieve_token, send_toke
|
||||
async def test_get_balance() -> None:
|
||||
mock_wallet = Mock()
|
||||
mock_wallet.available_balance = Mock(amount=50000)
|
||||
mock_wallet.load_mint = AsyncMock()
|
||||
mock_wallet.load_proofs = AsyncMock()
|
||||
|
||||
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
@@ -48,9 +49,9 @@ async def test_recieve_token_valid() -> None:
|
||||
mock_token.proofs = [{"amount": 1000}]
|
||||
mock_deserialize.return_value = mock_token
|
||||
|
||||
mock_wallet.load_mint = AsyncMock()
|
||||
mock_wallet.load_proofs = AsyncMock()
|
||||
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
mock_wallet.load_mint = AsyncMock()
|
||||
|
||||
amount, unit, mint = await recieve_token(token_str)
|
||||
assert amount == 1000
|
||||
assert unit == "sat"
|
||||
@@ -105,8 +106,9 @@ async def test_recieve_token_untrusted_mint() -> None:
|
||||
mock_token.amount = 1000
|
||||
mock_deserialize.return_value = mock_token
|
||||
|
||||
mock_wallet.load_mint = AsyncMock()
|
||||
mock_wallet.load_proofs = AsyncMock()
|
||||
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
mock_wallet.load_mint = AsyncMock()
|
||||
with patch(
|
||||
"routstr.wallet.swap_to_primary_mint",
|
||||
return_value=(900, "sat", "http://mint:3338"),
|
||||
|
||||
Reference in New Issue
Block a user