Compare commits

..
Author SHA1 Message Date
9qeklajc e4b825cad8 test 2025-08-06 00:09:43 +02:00
9qeklajc 6cff06f9aa test 2025-08-06 00:06:26 +02:00
9qeklajc 6c8a4a14ae test 2025-08-05 23:52:50 +02:00
9qeklajc 5c7820ef41 add prints 2025-08-05 23:51:32 +02:00
9qeklajc 047215df80 test 2025-08-05 23:43:41 +02:00
9qeklajc d69720f39f use own impl nutshell 2025-08-05 23:28:04 +02:00
9qeklajc 77a90ac7d6 again 2025-08-05 23:12:20 +02:00
9qeklajc 9741218edd update dockerfile 2025-08-05 23:10:37 +02:00
9qeklajc 1f3d2dcd62 dump fix 2025-08-05 22:04:20 +02:00
9qeklajc 55bb8ecf11 Merge branch 'main' into fix-x-cashu-refund 2025-08-05 19:36:14 +02:00
9qeklajc 8432561652 fix refund 2025-08-05 17:25:31 +02:00
319 changed files with 5639 additions and 71074 deletions
-13
View File
@@ -1,13 +0,0 @@
.env
.venv
.git
.gitignore
.dockerignore
compose.yml
compose.testing.yml
.todo
.github
.vscode
.DS_Store
**/node_modules
ui/.next
+32 -35
View File
@@ -1,43 +1,40 @@
# Core Configuration
UPSTREAM_BASE_URL=https://api.openai.com/v1
UPSTREAM_API_KEY=your-upstream-api-key
NAME = "Your Routstr Proxy Name"
DESCRIPTION = "A short Description"
# 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
# NSEC=nsec1...
# HTTP_URL=https://api.mynode.com
# ONION_URL=http://mynode.onion (auto fetched from compose)
# RELAYS="wss://relay.damus.io,wss://relay.nostr.band,wss://eden.nostr.land,wss://relay.routstr.com"
# CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org,https://ecashmint.otrta.me"
# RECEIVE_LN_ADDRESS=
# When your cashu balance reaches this number of sats, send the funds to RECEIVE_LN_ADDRESS.
MINIMUM_PAYOUT = "100"
# Custom 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
# 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"
# Network Configuration
# CORS_ORIGINS=*
# TOR_PROXY_URL=socks5://127.0.0.1:9050
# 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
# Logging
# LOG_LEVEL=INFO
# ENABLE_CONSOLE_LOGGING=true
# password used to log into admin interface
ADMIN_PASSWORD="CHANGE-THIS"
# Custom Model Management
# BASE_URL=https://openrouter.ai/api/v1
# MODELS_PATH=models.json
# SOURCE=
# Public Endpoint
HTTP_URL="https://your.domain.com"
# UI Configuration (for Next.js frontend)
# These variables are prefixed with NEXT_PUBLIC_ to be accessible in the browser
# NEXT_PUBLIC_API_URL=http://127.0.0.1:8000
# 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
+4 -2
View File
@@ -25,11 +25,13 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Lowercase and set image tag
run: echo "IMAGE_TAG=ghcr.io/$(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]'):latest" >> $GITHUB_ENV
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: |
ghcr.io/routstr/proxy:latest
ghcr.io/routstr/core:latest
${{ env.IMAGE_TAG }}
-56
View File
@@ -1,56 +0,0 @@
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 -39
View File
@@ -7,7 +7,7 @@ on:
branches: ["*"] # Run on PRs to all branches
jobs:
backend-test:
test:
runs-on: ubuntu-latest
strategy:
matrix:
@@ -36,9 +36,6 @@ jobs:
uv run mypy .
- name: Run tests with pytest
env:
UPSTREAM_BASE_URL: "http://test"
UPSTREAM_API_KEY: "test"
run: |
uv run pytest --verbose --tb=short
@@ -51,38 +48,3 @@ jobs:
pytest.xml
.coverage
retention-days: 30
ui-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "pnpm"
cache-dependency-path: ui/pnpm-lock.yaml
- name: Install UI dependencies
working-directory: ./ui
run: pnpm install --frozen-lockfile
- name: Run UI format check
working-directory: ./ui
run: pnpm run format-check
- name: Run UI linting
working-directory: ./ui
run: pnpm run lint
- name: Run UI build
working-directory: ./ui
run: pnpm run build
-16
View File
@@ -3,25 +3,12 @@ __pycache__
keys.db
wallet.sqlite3
# Python build artifacts
*.egg-info/
build/
dist/
*.egg
.mypy_cache/**
# Development
.notes
.*keys.db
*.db-shm
*.db-wal
.*wallet.sqlite3
*models.json
.cashu
.relay
relay-data
.dockerignore
relay-data
compose.override.yml
@@ -35,6 +22,3 @@ logs/*
# deployment
proof_backups
*.todo
ui_out
-1
View File
@@ -1 +0,0 @@
3.11
-370
View File
@@ -1,370 +0,0 @@
# Contributing to Routstr Proxy
We welcome contributions to Routstr Proxy! This document provides guidelines and instructions for contributing to the project.
## Table of Contents
- [Getting Started](#getting-started)
- [Development Setup](#development-setup)
- [Code Standards](#code-standards)
- [Testing](#testing)
- [Submitting Changes](#submitting-changes)
- [Project Structure](#project-structure)
- [Documentation](#documentation)
- [Release Process](#release-process)
## Getting Started
### Prerequisites
- Python 3.11 or higher
- [uv](https://docs.astral.sh/uv/) package manager
- Docker and Docker Compose (optional, for integration tests)
- Git
### Development Setup
1. **Fork and clone the repository**
```bash
git clone https://github.com/YOUR_USERNAME/routstr-proxy.git
cd routstr-proxy
```
2. **Set up the development environment**
```bash
make setup
```
This will:
- Install `uv` if not already installed
- Create a virtual environment
- Install all dependencies including dev tools
- Install the project in editable mode
3. **Configure environment variables**
```bash
cp .env.example .env
# Edit .env with your configuration
```
4. **Verify your setup**
```bash
make check-deps
make test-unit
```
## Code Standards
### Python Style Guide
We use modern Python 3.11+ features and enforce strict type checking:
- **Type Hints**: All functions must have complete type annotations
```python
# ✅ Good
def calculate_cost(tokens: int, price_per_token: float) -> dict[str, float]:
return {"total": tokens * price_per_token}
# ❌ Bad
def calculate_cost(tokens, price_per_token):
return {"total": tokens * price_per_token}
```
- **Type Syntax**: Use Python 3.11+ lowercase types
```python
# ✅ Good
def process_items(items: list[dict[str, str | None]]) -> dict[str, int]:
...
# ❌ Bad
from typing import List, Dict, Optional
def process_items(items: List[Dict[str, Optional[str]]]) -> Dict[str, int]:
...
```
- **Comments**: Only add comments for non-obvious logic. Code should be self-documenting
```python
# ✅ Good - complex business logic explained
# Apply exponential backoff with jitter to prevent thundering herd
delay = min(base_delay * (2 ** attempt) + random.uniform(0, 1), max_delay)
# ❌ Bad - obvious comment
# Increment counter by 1
counter += 1
```
### Code Quality Tools
We enforce code quality using:
- **Ruff**: For linting and formatting
```bash
make lint # Check for issues
make format # Auto-fix formatting
```
- **Mypy**: For type checking
```bash
make type-check
```
### Commit Messages
Follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
```text
<type>(<scope>): <subject>
<body>
<footer>
```
Types:
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation changes
- `style`: Code style changes (formatting, etc.)
- `refactor`: Code refactoring
- `test`: Test additions or fixes
- `chore`: Build process or auxiliary tool changes
Examples:
```text
feat(proxy): add support for streaming responses
fix(wallet): handle expired tokens correctly
docs: update API documentation for v2 endpoints
```
## Testing
### Test Structure
Tests are organized into:
- `tests/unit/` - Fast, isolated unit tests
- `tests/integration/` - Integration tests (can use mocks or real services)
### Running Tests
```bash
# Run all tests (unit + integration with mocks)
make test
# Run specific test suites
make test-unit # Unit tests only
make test-integration # Integration tests with mocks
make test-integration-docker # Integration tests with real services
make test-performance # Performance benchmarks
# Advanced testing
make test-coverage # Generate coverage report
make test-fast # Skip slow tests
make test-failed # Re-run only failed tests
```
### Writing Tests
1. **Use pytest fixtures** for reusable test setup
2. **Mark async tests** with `@pytest.mark.asyncio`
3. **Use appropriate markers**:
```python
@pytest.mark.slow
@pytest.mark.requires_docker
async def test_complex_integration():
...
```
4. **Follow the AAA pattern**: Arrange, Act, Assert
```python
async def test_token_validation():
# Arrange
token = create_test_token(amount=1000)
# Act
result = await validate_token(token)
# Assert
assert result.is_valid
assert result.amount == 1000
```
## Submitting Changes
### Pull Request Process
1. **Create a feature branch**
```bash
git checkout -b feat/your-feature-name
```
2. **Make your changes**
- Write code following our standards
- Add or update tests
- Update documentation if needed
3. **Run quality checks**
```bash
make lint
make type-check
make test
```
4. **Commit your changes**
- Use conventional commit messages
- Keep commits focused and atomic
5. **Push and create a PR**
- Push to your fork
- Create a PR against the `main` branch
- Fill out the PR template completely
- Link any related issues
### PR Review Checklist
Before requesting review, ensure:
- [ ] All tests pass
- [ ] Code follows style guidelines
- [ ] Type hints are complete and correct
- [ ] Documentation is updated
- [ ] Commit messages follow conventions
- [ ] No unnecessary changes outside scope
### What to Expect
- Reviews typically happen within 2-3 business days
- Be prepared to make changes based on feedback
- Engage constructively in discussions
- Once approved, a maintainer will merge your PR
## Project Structure
```text
routstr-proxy/
├── routstr/ # Main application code
│ ├── core/ # Core functionality
│ │ ├── admin.py # Admin interface
│ │ ├── db.py # Database models and operations
│ │ ├── logging.py # Logging configuration
│ │ └── main.py # FastAPI app initialization
│ ├── payment/ # Payment processing
│ │ ├── cost_calculation.py
│ │ ├── models.py
│ │ └── x_cashu.py # Cashu integration
│ ├── auth.py # Authentication
│ ├── proxy.py # Request proxying logic
│ └── wallet.py # Wallet management
├── tests/ # Test suite
│ ├── unit/ # Unit tests
│ └── integration/ # Integration tests
├── scripts/ # Utility scripts
├── compose.yml # Docker compose for production
├── compose.testing.yml # Docker compose for testing
├── Makefile # Development commands
└── pyproject.toml # Project configuration
```
### Key Components
- **FastAPI Application**: Main API server in `routstr/core/main.py`
- **Database Models**: SQLModel definitions in `routstr/core/db.py`
- **Payment Logic**: Cashu integration and cost calculation in `routstr/payment/`
- **Proxy Handler**: Request forwarding logic in `routstr/proxy.py`
## Documentation
### Code Documentation
- Use descriptive variable and function names
- Add docstrings for public APIs:
```python
async def redeem_token(token: str, mint_url: str) -> RedemptionResult:
"""Redeem a Cashu token and credit the account.
Args:
token: Base64-encoded Cashu token
mint_url: URL of the Cashu mint
Returns:
RedemptionResult with amount and status
Raises:
TokenInvalidError: If token is malformed or expired
MintConnectionError: If mint is unreachable
"""
```
### API Documentation
- Update OpenAPI schemas when adding endpoints
- Keep `README.md` examples current
- Document environment variables in `.env.example`
### Architecture Decisions
For significant changes, create an ADR (Architecture Decision Record) in `docs/adr/`:
```markdown
# ADR-001: Use SQLite for Local Storage
## Status
Accepted
## Context
We need a simple, embedded database for storing API keys and balances.
## Decision
Use SQLite with SQLModel ORM for type safety and async support.
## Consequences
- No external database required
- Simple deployment
- Limited concurrent write performance
```
## Release Process
### Version Numbering
We use [Semantic Versioning](https://semver.org/):
- MAJOR: Breaking API changes
- MINOR: New features, backwards compatible
- PATCH: Bug fixes and minor improvements
### Release Steps
1. Update version in `pyproject.toml`
2. Update `CHANGELOG.md` with release notes
3. Create a git tag: `git tag -a v1.2.3 -m "Release v1.2.3"`
4. Push tag: `git push origin v1.2.3`
5. GitHub Actions will build and publish Docker images
## Getting Help
- **Issues**: Check existing issues or create a new one
- **Discussions**: Use GitHub Discussions for questions
- **Security**: Report security issues privately to maintainers
## License
By contributing, you agree that your contributions will be licensed under the GPLv3 license.
+25 -15
View File
@@ -1,29 +1,39 @@
FROM ghcr.io/astral-sh/uv:python3.11-alpine
FROM python:3.13-slim
# Install system dependencies required for secp256k1
RUN apk add --no-cache \
pkgconf \
build-base \
automake \
# Install system dependencies required for secp256k1 and git
RUN apt-get update && apt-get install -y \
build-essential \
pkg-config \
autoconf \
automake \
libtool \
m4 \
perl
RUN apk add git
cmake \
git \
libffi-dev \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
COPY uv.lock pyproject.toml ./
RUN mkdir -p /routstr
RUN uv add git+https://github.com/saschanaz/secp256k1-py.git#branch=upgrade060
# RUN uv sync
# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
# Set working directory
WORKDIR /app
# Copy dependency files
COPY uv.lock pyproject.toml ./
# Install dependencies
RUN uv sync --frozen
# Copy application code
COPY . .
# Set environment variables
ENV PORT=8000
ENV PYTHONUNBUFFERED=1
# Expose port
EXPOSE 8000
CMD ["/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"]
# Run the application
CMD ["uv", "run", "fastapi", "run", "router", "--host", "0.0.0.0"]
-285
View File
@@ -1,285 +0,0 @@
# Makefile for Routstr Proxy
# Detect if we're in a virtual environment
VENV_EXISTS := $(shell test -d .venv && echo 1)
ifeq ($(VENV_EXISTS), 1)
PYTHON := .venv/bin/python
PYTEST := .venv/bin/pytest
RUFF := .venv/bin/ruff
MYPY := .venv/bin/mypy
ALEMBIC := .venv/bin/alembic
else
PYTHON := python
PYTEST := pytest
RUFF := ruff
MYPY := mypy
ALEMBIC := alembic
endif
.PHONY: help setup test test-unit test-integration test-integration-docker test-all test-fast test-performance clean docker-up docker-down lint format type-check dev-setup check-deps db-upgrade db-downgrade db-current db-history db-migrate db-revision db-heads db-clean ui-build ui-build-docker ui-dev
# Default target
help:
@echo "Available targets:"
@echo " make test - Run all tests (unit + integration with mocks)"
@echo " make test-unit - Run unit tests only"
@echo " make test-integration - Run integration tests with mocks (fast)"
@echo " make test-integration-docker - Run integration tests with Docker services"
@echo " make test-all - Run all tests including Docker integration"
@echo " make test-fast - Run fast tests only (skip slow tests)"
@echo " make test-performance - Run performance tests"
@echo " make docker-up - Start Docker test services"
@echo " make docker-down - Stop Docker test services"
@echo " make clean - Clean up test artifacts and caches"
@echo " make lint - Run linting checks"
@echo " make format - Format code with ruff"
@echo " make type-check - Run mypy type checking"
@echo " make dev-setup - Set up development environment"
@echo " make check-deps - Check system dependencies"
@echo " make setup - First-time project setup"
@echo ""
@echo "UI targets:"
@echo " make ui-build - Build UI for production (static export)"
@echo " make ui-build-docker - Build UI using Docker (no Node.js needed)"
@echo " make ui-dev - Start UI development server"
@echo ""
@echo "Docker UI build requires only Docker, no local Node.js installation needed."
@echo "Database migration shortcuts:"
@echo " make create-migration - Auto-generate new migration"
@echo " make db-upgrade - Apply all pending migrations"
@echo " make db-downgrade - Downgrade one migration"
# First-time setup
setup: check-deps dev-setup
@echo ""
@echo "🎉 Setup complete! Next steps:"
@echo " 1. Run tests: make test"
@echo " 2. Run integration: make test-integration-docker"
@echo " 3. Start developing!"
# Test targets
test: test-unit test-integration
test-unit:
@echo "🧪 Running unit tests..."
$(PYTEST) tests/unit/ -v
test-integration:
@echo "🎭 Running integration tests with mocks..."
$(PYTEST) tests/integration/ -v
test-integration-docker:
@echo "🐳 Running integration tests with Docker services..."
./tests/run_integration.py
test-all: test-unit test-integration-docker
test-fast:
@echo "⚡ Running fast tests only..."
$(PYTEST) -m "not slow and not requires_docker" -v
test-performance:
@echo "📊 Running performance tests..."
$(PYTEST) tests/integration/ -m "performance" -v -s
# Docker management
docker-up:
@echo "🚀 Starting Docker test services..."
docker-compose -f compose.testing.yml up -d
@echo "Waiting for services to be ready..."
@sleep 5
@echo "Services started. Run 'make test-integration-docker' to test."
docker-down:
@echo "🛑 Stopping Docker test services..."
docker-compose -f compose.testing.yml down -v
# Code quality
lint:
@echo "🔍 Running linting checks..."
$(RUFF) check .
$(MYPY) routstr/ --ignore-missing-imports
format:
@echo "✨ Formatting code..."
$(RUFF) format .
$(RUFF) check --fix .
type-check:
@echo "🔎 Running type checks..."
$(MYPY) routstr/ --ignore-missing-imports
# Development setup
dev-setup:
@echo "🔧 Setting up development environment..."
@# Check if uv is installed
@if ! command -v uv >/dev/null 2>&1; then \
echo "📦 uv not found. Installing uv..."; \
if command -v curl >/dev/null 2>&1; then \
curl -LsSf https://astral.sh/uv/install.sh | sh; \
elif command -v pip >/dev/null 2>&1; then \
pip install uv; \
else \
echo "❌ Neither curl nor pip found. Please install uv manually:"; \
echo " Visit https://docs.astral.sh/uv/getting-started/installation/"; \
exit 1; \
fi; \
echo "✅ uv installed successfully!"; \
else \
echo "✅ uv is already installed (version: $$(uv --version))"; \
fi
uv sync --dev
uv pip install -e .
@echo "✅ Development environment ready!"
# Check dependencies
check-deps:
@echo "🔍 Checking system dependencies..."
@echo ""
@echo "Core tools:"
@printf " %-18s" "Python:"; if command -v python >/dev/null 2>&1; then python --version; else echo "❌ Not found"; fi
@printf " %-18s" "uv:"; if command -v uv >/dev/null 2>&1; then uv --version; else echo "❌ Not found - run 'make dev-setup' to install"; fi
@printf " %-18s" "Docker:"; if command -v docker >/dev/null 2>&1; then docker --version; else echo "⚠️ Not found (optional, needed for integration tests)"; fi
@printf " %-18s" "Docker Compose:"; if command -v docker-compose >/dev/null 2>&1; then docker-compose --version; else echo "⚠️ Not found (optional, needed for integration tests)"; fi
@echo ""
@echo "Development tools:"
@printf " %-18s" "pytest:"; if $(PYTEST) --version >/dev/null 2>&1; then $(PYTEST) --version | head -1; else echo "❌ Not found - run 'make dev-setup'"; fi
@printf " %-18s" "ruff:"; if $(RUFF) --version >/dev/null 2>&1; then $(RUFF) --version; else echo "❌ Not found - run 'make dev-setup'"; fi
@printf " %-18s" "mypy:"; if $(MYPY) --version >/dev/null 2>&1; then $(MYPY) --version; else echo "❌ Not found - run 'make dev-setup'"; fi
@printf " %-18s" "alembic:"; if $(ALEMBIC) --version >/dev/null 2>&1; then $(ALEMBIC) --version; else echo "❌ Not found - run 'make dev-setup'"; fi
@echo ""
@echo "Virtual environment:"
@if [ -d ".venv" ]; then \
echo " ✅ .venv exists"; \
echo " Python: $$(.venv/bin/python --version)"; \
else \
echo " ❌ .venv not found - run 'make dev-setup'"; \
fi
@echo ""
@echo "To set up missing dependencies, run: make dev-setup"
# Cleanup
clean:
@echo "🧹 Cleaning up..."
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete
find . -type f -name ".coverage" -delete
rm -rf htmlcov/
rm -rf dist/
rm -rf build/
rm -rf *.egg-info
@echo "✨ Cleanup complete!"
# Database migration management
db-upgrade:
@echo "⬆️ Applying all pending migrations..."
$(ALEMBIC) upgrade head
@echo "✅ Database upgraded to latest revision"
db-downgrade:
@echo "⬇️ Downgrading one migration..."
$(ALEMBIC) downgrade -1
@echo "✅ Database downgraded by one revision"
db-current:
@echo "📍 Current database revision:"
$(ALEMBIC) current -v
db-history:
@echo "📜 Migration history:"
$(ALEMBIC) history --verbose
db-migrate:
@echo "🔍 Auto-generating migration from model changes..."
@read -p "Enter migration message: " msg; \
$(ALEMBIC) revision --autogenerate -m "$$msg"
@echo "✅ Migration generated. Review and edit if needed."
db-revision:
@echo "📝 Creating empty migration file..."
@read -p "Enter migration message: " msg; \
$(ALEMBIC) revision -m "$$msg"
@echo "✅ Empty migration created"
db-heads:
@echo "🎯 Current migration heads:"
$(ALEMBIC) heads
db-clean:
@echo "🧹 Cleaning migration cache files..."
find migrations/ -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
@echo "✅ Migration cache cleaned"
# Advanced testing options
test-coverage:
@echo "📊 Running tests with coverage..."
$(PYTEST) --cov=routstr --cov-report=html --cov-report=term
@echo "Coverage report generated in htmlcov/"
test-watch:
@echo "👁️ Running tests in watch mode..."
$(PYTEST)-watch
test-parallel:
@echo "🚀 Running tests in parallel..."
$(PYTEST) -n auto -v
# CI/CD specific targets
ci-test:
@echo "🤖 Running CI test suite..."
$(PYTEST) -m "not requires_docker" --tb=short -v
ci-lint:
@echo "🤖 Running CI linting..."
$(RUFF) check . --exit-non-zero-on-fix
$(MYPY) routstr/ --ignore-missing-imports --no-error-summary
# Debug helpers
test-debug:
@echo "🐛 Running tests with debugging enabled..."
$(PYTEST) -vvs --tb=long --pdb-trace
test-failed:
@echo "🔄 Re-running failed tests..."
$(PYTEST) --lf -v
# Performance profiling
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
# UI build
ui-build:
@echo "🎨 Building UI for static deployment..."
./scripts/build-ui.sh
ui-build-docker:
@echo "🐳 Building UI using Docker (no Node.js installation required)..."
@echo "Building UI with environment variables from .env..."
docker build -f ui/Dockerfile.build -t routstr-ui-build --build-arg NEXT_PUBLIC_API_URL=$(NEXT_PUBLIC_API_URL) --build-arg NEXT_PUBLIC_ADMIN_API_KEY=$(NEXT_PUBLIC_ADMIN_API_KEY) .
docker run --rm -v $(PWD)/ui_out:/output routstr-ui-build cp -r /ui_out /output/
@echo "✅ UI build complete! Static files available in ui_out/"
ui-dev:
@echo "🎨 Starting UI development server..."
cd ui && (command -v pnpm >/dev/null 2>&1 && pnpm run dev || npm run dev)
+146 -52
View File
@@ -1,69 +1,163 @@
# Routstr Payment Proxy
[![License](https://img.shields.io/github/license/routstr/routstr-core?style=flat-square)](LICENSE)
[![Stars](https://img.shields.io/github/stars/routstr/routstr-core?style=flat-square)](https://github.com/routstr/routstr-core/stargazers)
[![Issues](https://img.shields.io/github/issues/routstr/routstr-core?style=flat-square)](https://github.com/routstr/routstr-core/issues)
[![Release](https://img.shields.io/github/v/release/routstr/routstr-core?style=flat-square)](https://github.com/routstr/routstr-core/releases)
Routstr is a FastAPI-based reverse proxy that sits in front of any OpenAI-compatible API. It handles pay-per-request billing using the [Cashu](https://cashu.space/) eCash protocol on Bitcoin and tracks usage in a local SQL database.
Routstr is a decentralized protocol for permissionless, private, and censorship-resistant AI inference. It combines Nostr for discovery and Cashu for private Bitcoin micropayments.
The server exposes the same endpoints as the upstream API and deducts sats from user accounts for each call. Pricing can be static or model-specific by loading `models.json` (falls back to `models.example.json`).
This repo contains Routstr Core: a FastAPI-based reverse proxy that sits in front of OpenAI-compatible APIs and handles pay-per-request billing.
## How It Works
## Start Here
The proxy implements a seamless eCash payment flow that maintains compatibility with existing OpenAI clients while enabling Bitcoin micropayments:
- **Overview**: <https://docs.routstr.com/overview/>
- **Provider Guide**: <https://docs.routstr.com/provider/quickstart/>
- **User Guide**: <https://docs.routstr.com/user-guide/introduction/>
```mermaid
sequenceDiagram
participant Client
participant Proxy as Routstr Proxy
participant DB as Database
participant Upstream as OpenAI API
participant Wallet as Cashu Wallet
## Basic Usage
If you are a user/developer, you just point an OpenAI-compatible SDK at a Routstr node and pay with a Cashu token.
### OpenAI SDK
```python
from openai import OpenAI
client = OpenAI(
base_url="https://api.routstr.com/v1",
api_key="cashuBo2FteCJodHRwczovL21...",
)
response = client.chat.completions.create(
model="gpt-5-nano",
messages=[{"role": "user", "content": "hello"}],
)
print(response.choices[0].message.content)
Client->>Proxy: API Request + eCash Token
Proxy->>Wallet: Validate & Redeem Token
Wallet-->>Proxy: Token Value (sats)
Proxy->>DB: Store/Update Balance
Proxy->>Upstream: Forward API Request
Upstream-->>Proxy: API Response + Usage Data
Proxy->>DB: Deduct Actual Request Cost
Proxy->>DB: Update Final Balance
Proxy-->>Client: API Response
```
### cURL
## Features
- **Cashu Wallet Integration** Accept Lightning payments and redeem eCash tokens before forwarding requests
- **API Key Management** Hashed keys stored in SQLite with balance tracking and optional expiry/refund address
- **Model-Based Pricing** Convert USD prices in `models.json` to sats using live BTC/USD rates
- **Admin Dashboard** Simple HTML interface at `/admin` to view balances and API keys
- **Discovery** Fetch available providers from Nostr relays
- **Docker Support** Provided `Dockerfile` and `compose.yml` for running with an optional Tor hidden service
## Getting Started
### Requirements
- Python 3.11+
- [uv](https://github.com/astral-sh/uv) package manager (used in development)
- A Cashu wallet secret (`NSEC`) and Lightning address for receiving payments
### Installation
```bash
curl https://api.routstr.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "x-cashu: cashuBo2FteCJodHRwczovL21..." \
-d '{
"model": "gpt-5-nano",
"messages": [{"role": "user", "content": "hello"}]
}'
uv sync --dev # install dependencies
```
## Quick Start (Docker)
If you are a node runner, start a Routstr Core instance and configure upstream access in the dashboard.
Create a `.env` file based on `.env.example` and fill in the required values:
```bash
docker run -d \
--name routstr-proxy \
-p 8000:8000 \
ghcr.io/routstr/proxy:latest
```
## Development
```bash
make setup
cp .env.example .env
fastapi run routstr
```
### Running Locally
```bash
fastapi run router --host 0.0.0.0 --port 8000
```
The service forwards requests to `UPSTREAM_BASE_URL`. Supply the upstream API key via the `UPSTREAM_API_KEY` environment variable if required.
### Docker
```bash
docker compose up --build
```
This builds the image and also starts a Tor container exposing the API as a hidden service.
## Environment Variables
The most common settings are shown below. See `.env.example` for the full list.
- `UPSTREAM_BASE_URL` URL of the OpenAI-compatible service
- `UPSTREAM_API_KEY` API key for the upstream service (optional)
- `RECEIVE_LN_ADDRESS` Lightning address that receives payouts
- `MINIMUM_PAYOUT` Minimum sats before forwarding earnings
- `MODEL_BASED_PRICING` Set to `true` to use pricing from `models.json`
- `REFUND_PROCESSING_INTERVAL` Seconds between automatic refunds
- `ADMIN_PASSWORD` Password for the `/admin` dashboard
## Withdrawing Balance
Go to `https://<your.routstr.proxy>/admin/` (NOTE: be sure to add the '/' at the end), enter the `ADMIN_PASSWORD` you set above and withdraw your balance as a Cashu token.
## Example Client
`example.py` shows how to use the proxy with the official OpenAI client:
```bash
CASHU_TOKEN=<redeemable token> python example.py
```
The script sends streaming chat completions and pays for each request using the provided token.
## Running Tests
```bash
uv run pytest
```
The tests create a temporary SQLite database and mock the Cashu wallet. See `tests/README.md` for more details.
## Future Features
### Nut-24 Header Support (Coming Soon)
We're implementing support for the Cashu Nut-24 specification, which will enable per-request token exchange with automatic change handling:
```mermaid
graph TD
A["Client Request<br/>x-cashu: token"] --> B[Proxy Validates Token]
B --> C{Token ≥ Minimum Amount?}
C -->|No| F[Return 402 Payment Required]
C -->|Yes| D[Calculate Request Cost]
D --> E[Process Request]
E --> G[Forward to Upstream API]
G --> H[Receive API Response]
H --> I[Calculate Change]
I --> J["Return Response<br/>x-cashu: change_token"]
F --> K[End]
J --> K
```
**Key Benefits:**
- **Per-Request Payments** Send exact tokens for each API call
- **Automatic Change** Receive change tokens in response headers
- **No Pre-funding** No need to maintain account balances
- **Precise Billing** Pay only for actual usage with msat-level precision
- **Minimum Amount Protection** Proxy enforces minimum token value to prevent dust attacks
**Header Format:**
- **Request**: `x-cashu: <ecash_token>` Token to spend for this request (must meet minimum amount)
- **Response**: `x-cashu: <change_token>` Change token if payment exceeds cost
**Implementation Note:**
The proxy should implement either a dedicated endpoint to communicate minimum eCash requirements per request, or extend the existing `models.json` to include minimum token amounts per model. This allows clients to autonomously determine the appropriate token amount to send with each request.
**Compatible Clients:**
To use this feature, you'll need a client that handles both OpenAI API calls and eCash header management. The following clients provide seamless integration:
- **[cashu-402-client](https://github.com/9qeklajc/ecash-402-client)** rust client with automatic wallet management
clients automatically:
- **Handle eCash Headers** Add `x-cashu` tokens to requests and process change tokens
- **Manage Wallets** Maintain your Cashu wallet
- **Configure Proxy** Set Routstr proxy endpoints
- **Top-up Balances** Automatically request ecash when tokens run low and redeem ecash tokens
This approach eliminates the need for account management while maintaining the security and privacy benefits of eCash payments.
## License
This project is licensed under the terms of the GPLv3. See the `LICENSE` file for the full license text.
-35
View File
@@ -1,35 +0,0 @@
[alembic]
script_location = migrations
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
-28
View File
@@ -1,28 +0,0 @@
#!/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
-62
View File
@@ -1,62 +0,0 @@
version: '3.8'
services:
routstr:
build: .
command: ["/.venv/bin/fastapi", "dev", "routstr", "--host", "0.0.0.0", "--port", "8000"]
ports:
- "8000:8000"
environment:
- "DATABASE_URL=sqlite+aiosqlite:///:memory:"
- "NOSTR_RELAY_URL=ws://relay:8080"
- "UPSTREAM_BASE_URL=http://mock-openai:3000"
- "UPSTREAM_API_KEY=test-upstream-key"
- "CASHU_MINTS=http://mint:3338"
- "NAME=TestRoutstrNode"
- "DESCRIPTION=Test Node for Integration Tests"
- "NPUB=npub1test"
- "HTTP_URL=http://localhost:8000"
- "ONION_URL=http://test.onion"
- "CORS_ORIGINS=*"
- "RECEIVE_LN_ADDRESS=test@routstr.com"
- "FIXED_COST_PER_REQUEST=10"
- "FIXED_PER_1K_INPUT_TOKENS=0"
- "FIXED_PER_1K_OUTPUT_TOKENS=0"
- "FIXED_PRICING=false"
- "NSEC=nsec1testkey1234567890abcdef"
- "REFUND_PROCESSING_INTERVAL=3600"
- "MINIMUM_PAYOUT=1000"
- "PAYOUT_INTERVAL=86400"
depends_on:
- mock-mint
- mock-openai
- relay
relay:
image: scsibug/nostr-rs-relay:latest
restart: unless-stopped
ports:
- "8088:8080" # host:container
environment:
- LISTEN_ADDR=0.0.0.0
- LISTEN_PORT=8080
mock-openai:
image: zerob13/mock-openai-api
ports:
- "3000:3000"
mock-mint:
image: cashubtc/nutshell:0.17.0
container_name: mint
ports:
- "3338:3338"
environment:
- MINT_BACKEND_BOLT11_SAT=FakeWallet
- MINT_LISTEN_HOST=0.0.0.0
- MINT_LISTEN_PORT=3338
- MINT_PRIVATE_KEY=TEST_PRIVATE_KEY
command: poetry run mint
restart: unless-stopped
depends_on:
- mock-openai
+5 -23
View File
@@ -1,35 +1,17 @@
services:
ui:
env_file:
- .env
build:
context: ./ui
dockerfile: Dockerfile.build
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8000}
NEXT_PUBLIC_ADMIN_API_KEY: ${NEXT_PUBLIC_ADMIN_API_KEY:-}
volumes:
- ./ui_out:/output
command:
["sh", "-c", "mkdir -p /output && cp -r /app/built/. /output/ && echo 'UI build copied to mounted volume' && ls -la /output/ && echo 'UI built and ready' && tail -f /dev/null"]
version: '3.8'
routstr:
services:
router:
build: .
depends_on:
- ui
volumes:
- .:/app
- ./logs:/app/logs
- tor-data:/var/lib/tor:ro
- ./ui_out:/app/ui_out:ro
env_file:
- .env
environment:
- TOR_PROXY_URL=socks5://tor:9050
ports:
- 8000:8000
extra_hosts: # Needed to access locally running models
- "host.docker.internal:host-gateway"
tor:
image: ghcr.io/hundehausen/tor-hidden-service:latest
@@ -37,9 +19,9 @@ services:
- tor-data:/var/lib/tor
environment:
# Format: HS_<NAME>=<TARGET_HOST>:<TARGET_PORT>:<VIRTUAL_PORT>
- HS_ROUTER=routstr:8000:80
- HS_ROUTER=router:8000:80
depends_on:
- routstr
- router
volumes:
tor-data:
-428
View File
@@ -1,428 +0,0 @@
# 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](../client/integration.md) - Integration examples
-566
View File
@@ -1,566 +0,0 @@
# 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"
}
```
### Create Child Key
Creates one or more child API keys that share the parent's balance. Each child key creation costs a fixed amount (configurable).
```http
POST /v1/balance/child-key
Authorization: Bearer sk-...
```
**Request Body:**
```json
{
"count": 1
}
```
**Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `count` | integer | Yes | - | Number of child keys to create (1-50) |
**Response:**
```json
{
"api_keys": ["sk-abc...", "sk-def..."],
"count": 2,
"cost_msats": 2000,
"cost_sats": 2,
"parent_balance": 98000,
"parent_balance_sats": 98
}
```
## Provider Discovery
## Admin Settings
These endpoints are protected by the Admin cookie (`admin_password` set to your configured admin password).
### Get Settings
```http
GET /admin/api/settings
```
Returns the current application settings (sensitive values may be redacted).
### Update Settings
```http
PATCH /admin/api/settings
Content-Type: application/json
```
Body is a partial JSON of settings fields to update. Validated and persisted to the database.
### 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
- [Integration Guide](../client/integration.md) - Code examples
-592
View File
@@ -1,592 +0,0 @@
# 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
- [Integration Guide](../client/integration.md) - Error handling examples
-312
View File
@@ -1,312 +0,0 @@
# 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:
- **Models**: `/v1/models`
- **Responses**: `/v1/responses`
- **Chat Completions**: `/v1/chat/completions`
- **Embeddings**: `/v1/embeddings`
- **Completions**: `/v1/completions` *(planned)*
- **Images**: `/v1/images/generations` *(planned)*
- **Audio**: `/v1/audio/transcriptions` *(planned)*
### Payment Endpoints
Routstr-specific payment management:
- **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-Cashu` | eCash token for per-request payment |
#### X-Cashu: Stateless Per-Request Payment
Instead of using `Authorization: Bearer sk-...`, you can send a Cashu token directly in the `X-Cashu` header. The response will include an `X-Cashu-Refund` header with your change.
```bash
curl https://api.routstr.com/v1/chat/completions \
-H "X-Cashu: cashuA3s8jKx9..." \
-H "Content-Type: application/json" \
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello"}]}'
```
The response includes your change in the same header:
```
X-Cashu: cashuA7k2mNp4...
```
This is fully stateless—no session, no `/v1/balance/refund` call needed. However, **streaming does not work with `X-Cashu`** because the refund can only be calculated after the full response is generated.
## Response Headers
### Standard Headers
| Header | Description |
|--------|-------------|
| `Content-Type` | Response format |
| `Content-Length` | Response size |
| `X-Request-ID` | Unique request identifier |
| `X-Cashu` | Change token (when request used `X-Cashu` header) |
## 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`
## 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
## Batch Requests *(planned)*
Process multiple operations in one request. Coming soon.
## Node Info
Get node metadata:
```
GET /v1/info
```
Supported models and pricing are available at `/v1/models`.
## Next Steps
- [Authentication](authentication.md) - Detailed auth guide
- [Endpoints](endpoints.md) - Complete endpoint reference
- [Errors](errors.md) - Error handling guide
- [Integration Guide](../client/integration.md) - Code examples
-143
View File
@@ -1,143 +0,0 @@
# Using the API
Routstr is **OpenAI-compatible**. Almost any AI application, SDK, or tool that supports custom endpoints will work out of the box. Just change two things:
```
BASE_URL → https://api.routstr.com/v1
API_KEY → sk-... or cashuA...
```
**Both work as API keys:**
- `sk-7f8e9d...` — Session key (from Lightning invoice or Cashu import)
- `cashuA3s8j...` — Raw Cashu token (use directly from your wallet)
If the app lets you set a base URL and API key, you're good to go.
---
## Quick Setup Examples
### OpenAI SDK (Python/JS)
```python
client = OpenAI(base_url="https://api.routstr.com/v1", api_key="sk-...") # or any provider's URL
```
### Claude Code
```bash
export ANTHROPIC_BASE_URL=https://api.routstr.com/v1
export ANTHROPIC_AUTH_TOKEN=sk-...
```
### Any OpenAI-compatible app
Look for "Custom API endpoint", "Base URL", or "OpenAI-compatible" in settings. Paste the URL and key.
---
## Detailed Examples
### Python (Official SDK)
```python
from openai import OpenAI
# 1. Initialize with Routstr URL and your funded key
client = OpenAI(
base_url="https://api.routstr.com/v1",
api_key="sk-7f8e9d..."
)
# 2. Call the API normally
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello!"}],
stream=True
)
for chunk in response:
if chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="")
```
### Node.js
```javascript
import OpenAI from 'openai';
// You can use a session key OR a raw Cashu token directly
const openai = new OpenAI({
baseURL: 'https://api.routstr.com/v1',
apiKey: 'cashuA3s8jKx9...', // or 'sk-7f8e9d...'
});
async function main() {
const completion = await openai.chat.completions.create({
messages: [{ role: 'user', content: 'Say this is a test' }],
model: 'gpt-3.5-turbo',
});
console.log(completion.choices[0]);
}
main();
```
### cURL
```bash
# Works with session key or raw Cashu token
curl https://api.routstr.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer cashuA3s8jKx9..." \
-d '{
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "Hello!"}]
}'
```
---
## Error Handling
### Insufficient Balance (402 Payment Required)
If your session runs out of funds, the API will return a `402` error.
```json
{
"error": {
"message": "Insufficient balance. Current: 1000 msat, Required: 5000 msat",
"type": "insufficient_balance",
"code": 402
}
}
```
**Action**: Top up your key using the `/lightning/invoice` (topup purpose) or `/v1/balance/topup` endpoints.
### Rate Limiting
Routstr passes through rate limits from the upstream provider. Handle `429 Too Many Requests` with standard exponential backoff.
---
## Advanced: Tor Access
If the node is running as a hidden service, use a SOCKS5 proxy (like `127.0.0.1:9050`).
**Python:**
```python
import httpx
from openai import OpenAI
proxy_mounts = {
"http://": httpx.HTTPTransport(proxy="socks5://127.0.0.1:9050"),
"https://": httpx.HTTPTransport(proxy="socks5://127.0.0.1:9050"),
}
client = OpenAI(
base_url="http://verylongonionaddress.onion/v1",
api_key="sk-...",
http_client=httpx.Client(mounts=proxy_mounts),
)
```
-142
View File
@@ -1,142 +0,0 @@
# Introduction to Routstr
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 (Cashu eCash)
- Creating and managing API keys (Ephemeral Sessions)
- Making API calls through Routstr
- Using the admin dashboard
## Prerequisites
### 💰 Wallet
Cashu ([cashu.me](https://cashu.me)) or Lightning ([Strike](https://strike.me), Cash App, etc.)
### 🌐 Provider
A Routstr node, e.g. `https://api.routstr.com`
### 🤖 Client
OpenAI SDK, Claude Code, Cursor, or any OpenAI-compatible tool
---
## How Routstr Works
Routstr is a **Payment Proxy**. It sits between your code and the AI provider.
### Traditional API vs Routstr
| Traditional | Routstr |
|---|---|
| Credit Card Required | Bitcoin / Lightning / eCash |
| Monthly Billing | Pay-per-request (Real-time) |
| KYC / Account | No Account / Private |
| Single Provider | Aggregated Providers |
### Key Concepts
#### 1. Cashu eCash
Digital bearer tokens backed by Bitcoin. They are instant, private, and have no fees for internal transfers. Routstr uses these tokens as the "credits" for API requests.
#### 2. Ephemeral Sessions (API Keys)
Instead of a permanent account, you create a **Session**.
- You fund a session with eCash or Lightning.
- Routstr gives you an `api_key` (`sk-...`) representing that session.
- You use the `api_key` until funds run out or you finish your task.
- You can **refund** the remaining balance back to your wallet at any time.
#### 3. Millisats (msats)
Everything is priced in **millisatoshis**.
- 1 Satoshi (sat) = 1,000 msats.
- This allows for extremely precise pricing (e.g., 0.05 sats per prompt).
---
## Workflow: Zero to Intelligence
### 1. Fund a Session
You need an `api_key` with a balance.
**Easiest: Use the Web UI**
Visit the node's root page (e.g., [api.routstr.com](https://api.routstr.com)) or [chat.routstr.com](https://chat.routstr.com) → Settings to create a key visually with Lightning.
**Option A: Lightning Invoice (CLI)**
Generate an invoice and pay it with any Lightning wallet.
```bash
curl -X POST https://api.routstr.com/lightning/invoice \
-d '{"amount_sats": 1000, "purpose": "create"}'
```
*Returns an invoice (`bolt11`) and an ID. Once paid, the status endpoint returns your `api_key`.*
**Option B: Cashu Token (Best for privacy & devs)**
If you have a Cashu wallet, you can copy a token string (`cashuA...`) and use it directly.
- **Direct Usage**: Use the token *as* your API key in the `Authorization` header.
- **Import**: Or exchange it for a standard `sk-...` key:
```bash
curl "https://api.routstr.com/v1/balance/create?initial_balance_token=cashuA..."
```
*Returns your `api_key` immediately.*
### 2. Configure Your Client
Use the standard OpenAI SDK, just changing the `base_url` and `api_key`.
```python
from openai import OpenAI
client = OpenAI(
base_url="https://api.routstr.com/v1",
api_key="sk-7f8e9d..." # The key from Step 1
)
```
### 3. Make Requests
```python
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Explain quantum computing."}]
)
```
### 4. Withdraw Change
When you are done, get your change back as a Cashu token.
```bash
curl -X POST https://api.routstr.com/v1/balance/refund \
-H "Authorization: Bearer sk-7f8e9d..."
```
*Returns a `token` that you can paste back into Nutstash or Minibits to reclaim your funds.*
---
## Supported Features
- **Responses**: `/v1/responses` (OpenAI Responses API)
- **Chat Completions**: `/v1/chat/completions` (Streaming supported)
- **Embeddings**: `/v1/embeddings`
- **Models**: `/v1/models` (List available models and prices)
## Next Steps
- **[Payment Flow](payments.md)**: Detailed breakdown of the funding lifecycle.
- **[Models & Pricing](../provider/pricing.md)**: How costs are calculated.
- **[Admin Dashboard](../provider/dashboard.md)**: Managing your node if you are the operator.
-97
View File
@@ -1,97 +0,0 @@
# Payment Flow
Routstr uses a **Pre-paid, Ephemeral** payment model. Pay first, use the funds, withdraw the rest. No accounts, no credit cards, no trails.
```
💰 Deposit → 🤖 Use AI → 💸 Withdraw Change
```
## 1. Creating a Balance (Deposit)
To start making requests, you must create a "Balance" (represented by an API Key).
### Method A: Lightning Network (Bolt11)
**Ideal for**: Users connecting from a standard Lightning wallet (Strike, Cash App, WoS).
1. **Request Invoice**:
`POST /lightning/invoice` with `{"amount_sats": 5000, "purpose": "create"}`.
2. **Pay Invoice**: User scans and pays the QR code/bolt11 string.
3. **Receive Key**: Routstr detects the payment and issues a new API Key (`sk-...`) pre-loaded with 5,000 sats (5,000,000 msats).
### Method B: Cashu Token Import
**Ideal for**: Private, instant access or automated agents.
1. **Generate Token**: User creates a token in their local wallet (e.g., 1000 sats).
2. **Import**: `GET /v1/balance/create?initial_balance_token=cashuA...`
3. **Receive Key**: Routstr claims the token and issues an API Key (`sk-...`) with that balance.
---
## 2. Consuming Funds (Inference)
Every time you make a request to `/v1/chat/completions` (or others), the cost is deducted from your balance **in real-time**.
### Cost Calculation
`Cost = (Input_Tokens * Price_Input) + (Output_Tokens * Price_Output) + Request_Fee`
- Prices are defined per model (see `/v1/models`).
- If you stream the response, the balance is deducted incrementally or finalized at the end of the stream.
- If your balance hits 0 mid-stream, the connection is closed.
### Headers
Routstr checks the `Authorization: Bearer sk-...` header to identify which balance to charge.
---
## 3. Topping Up
If your balance runs low, you don't need a new key. You can top up the existing one.
### Via Lightning
`POST /lightning/invoice` with `{"amount_sats": 1000, "purpose": "topup", "api_key": "sk-..."}`.
*Once paid, the funds are added to your existing key.*
### Via Cashu
`POST /v1/balance/topup` with `{"cashu_token": "..."}` and `Authorization: Bearer sk-...`.
---
## 4. Refund (Withdrawal)
Don't leave large balances sitting on a node—it's a hot wallet. When you're done, get your sats back.
### Endpoint
`POST /v1/balance/refund`
**Headers**:
`Authorization: Bearer sk-...`
**Response**:
```json
{
"token": "cashuAeyJ0b2tlbiI6W3sibWludCI6...",
"msats": "450000"
}
```
You can verify the refund was successful by checking that the API Key is now invalid or has 0 balance. Copy the `token` string and paste it into your Cashu wallet to claim the Bitcoin.
---
## Summary
| Step | Action | Result |
|------|--------|--------|
| 💰 **Deposit** | Pay Lightning invoice or import Cashu | Get `sk-...` key |
| 🤖 **Use** | Make API requests | Balance decreases |
| 💸 **Refund** | Call `/v1/balance/refund` | Get Cashu token back |
That's it. No monthly bills, no surprise charges, no data harvesting.
-329
View File
@@ -1,329 +0,0 @@
# 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 and can optionally announce providers via Nostr.
```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[(SQLModel 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**: Runs migrations, initializes DB, refreshes pricing/models, starts background tasks
- **Middleware**: CORS and request logging
- **Routers**: Admin, pricing/models, balance/wallet, providers discovery, proxy
- **Background Tasks**: Price refresh, model map refresh, payouts, node announcements, provider discovery refresh
### Authentication System
Located in `routstr/auth.py`, handles:
- **API Key Validation**: SHA-256 hashed key lookup and persistence
- **Balance Checking**: Ensures sufficient funds before requests
- **Token Redemption**: Converts Cashu tokens to balance
### Payment Processing
The `routstr/payment/` module manages:
- **Cost Calculation**: Token-based or fixed pricing
- **Model Pricing**: Derived from upstream providers and DB overrides
- **Currency Conversion**: BTC/USD price refresh and conversion
- **Fee Application**: Provider fee applied to upstream model pricing
### Request Proxying
`routstr/proxy.py` handles:
- **Request Forwarding**: Forwards requests to selected upstream providers
- **Response Streaming**: Streaming and non-streaming paths
- **Usage Tracking**: Adjusts costs after upstream responses
- **Error Handling**: Maps upstream errors to consistent responses
### Database Layer
Using SQLModel in `routstr/core/db.py`:
```python
# Core tables
ApiKey:
- hashed_key: Primary key (SHA-256 of key or Cashu token)
- balance: Current balance (msats)
- reserved_balance: Reserved balance (msats)
- refund_address: Optional LNURL for refunds
- key_expiry_time: Optional refund expiry timestamp
- total_spent: Total spent (msats)
- total_requests: Request count
- refund_mint_url: Mint URL for refunds
- refund_currency: Refund currency
UpstreamProviderRow:
- id: Primary key
- provider_type: openai/anthropic/azure/openrouter/etc.
- base_url: Provider API base URL
- api_key: Provider API key
- api_version: Optional API version
- enabled: Provider enabled flag
- provider_fee: Provider fee multiplier
ModelRow:
- id: Model ID
- upstream_provider_id: Provider foreign key
- name: Model name
- architecture: JSON
- pricing: JSON
- sats_pricing: JSON
- per_request_limits: JSON
- top_provider: JSON
- canonical_slug: Canonical model slug
- alias_ids: Model aliases
- enabled: Model enabled flag
LightningInvoice:
- id: Primary key
- bolt11: Invoice
- amount_sats: Amount in sats
- payment_hash: Payment hash
- status: pending/paid/expired/cancelled
- api_key_hash: Optional associated API key
- purpose: create/topup
- created_at: Unix timestamp
- expires_at: Unix timestamp
- paid_at: Unix timestamp
```
## 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: Reserve Max Cost
R->>P: Forward Request
P-->>R: AI Response
R->>D: Finalize Cost (adjust by usage)
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: Request + Cashu Token
R->>W: Redeem Token
W->>M: Verify with Mint
M-->>W: Token Valid
W-->>R: Token Amount
R->>D: Create/Update Key + Balance
R-->>C: Continue Request
```
## Key Design Decisions
### 1. Async Architecture
The system is async end-to-end, with background tasks for pricing refresh, provider discovery, model map refresh, and payouts.
### 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
Exceptions are handled by FastAPI exception handlers to return consistent JSON responses with a request ID.
### 4. Database Migrations
Alembic migrations are run on startup, and tables are created for any models not tracked by migrations.
## Security Architecture
### API Key Security
- **Storage**: SHA-256 hashed keys
- **Generation**: Cryptographically secure random (when creating new keys)
- **Validation**: Hash lookup in DB
- **Expiry**: Optional refund flow via `key_expiry_time` and `refund_address`
### Payment Security
- **Token Validation**: Cashu token redemption via mint
- **Balance Protection**: Atomic updates and reserved balance tracking
- **Audit Trail**: Structured logging of payments and adjustments
### Network Security
- **CORS**: Configurable origins
- **Input Validation**: Pydantic models
## Performance Considerations
### Caching Strategy
Model and provider selections are cached in process memory and refreshed on a schedule.
### Database Optimization
- **Async I/O**: Non-blocking queries
- **Atomic Updates**: Balance reservation and finalization updates
### Streaming Responses
Streaming responses are forwarded from upstream providers with usage tracking hooks.
## 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
### Integration Tests
- Test component interactions
- Use test database
- Mock external services
- Verify end-to-end flows
### Performance Tests
- Response time benchmarks (as needed)
## 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
Structured logs are emitted for requests, pricing, and payment events.
### Health Checks
Use `/v1/info` for basic service metadata and configuration visibility.
## Deployment Architecture
### Container Structure
See `core/Dockerfile` for the current container build configuration.
### Environment Configuration
- **Development**: Local SQLite, debug logging
- **Testing**: In-memory database, mock services
- **Production**: Persistent storage, structured logs
### Scaling Considerations
- **Horizontal**: Multiple instances behind a load balancer
- **Vertical**: Async handles high concurrency
- **Database**: Configure `DATABASE_URL` for external databases
## 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
-443
View File
@@ -1,443 +0,0 @@
# 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, exports FastAPI app
│ ├── algorithm.py # Model selection/mapping logic
│ ├── auth.py # Bearer/Cashu auth and payment handling
│ ├── balance.py # Balance management endpoints
│ ├── discovery.py # Nostr relay discovery
│ ├── lightning.py # Lightning invoice topups
│ ├── nip91.py # Node announcement logic
│ ├── 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 # Exception handlers
│ │ ├── 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
│ │
│ └── upstream/ # Upstream provider integrations
│ ├── base.py # Base provider logic
│ ├── helpers.py # Provider init and model refresh
│ └── ... # Provider implementations
├── 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
│ └── ... # Build/update helpers
├── examples/ # Example clients
├── testing-clients/ # HTML test clients
├── ui/ # Next.js admin UI
├── 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
from .core.main import app as fastapi_app
__all__ = ["fastapi_app"]
```
#### `routstr/core/main.py`
```python
# FastAPI application setup
app = FastAPI(version=__version__, lifespan=lifespan)
# Middleware registration
app.add_middleware(CORSMiddleware, ...)
app.add_middleware(LoggingMiddleware)
# Router inclusion
app.include_router(models_router)
app.include_router(admin_router)
app.include_router(balance_router)
app.include_router(deprecated_wallet_router)
app.include_router(providers_router)
app.include_router(proxy_router)
```
### Authentication Module
#### `routstr/auth.py`
Handles bearer key validation and payment lifecycle (bearer or Cashu token):
```python
async def validate_bearer_key(
bearer_key: str,
session: AsyncSession,
refund_address: Optional[str] = None,
key_expiry_time: Optional[int] = None,
) -> ApiKey:
"""Validate bearer API key or redeem Cashu token into a balance."""
```
Key functions:
- `validate_bearer_key()` - Validate API key or Cashu token
- `pay_for_request()` - Reserve max cost before upstream call
- `adjust_payment_for_tokens()` - Adjust final cost after response
- `revert_pay_for_request()` - Refund on upstream failure
### Payment Processing
#### `routstr/payment/cost_calculation.py`
Calculates request costs:
```python
async def calculate_cost(
response_data: dict, max_cost: int, session: AsyncSession
) -> CostData | MaxCostData | CostDataError:
"""Calculate cost in millisatoshis from response usage or model pricing."""
```
#### `routstr/payment/models.py`
Manages model pricing, database overrides, and pricing refresh:
```python
class Model(BaseModel):
id: str
name: str
pricing: Pricing
sats_pricing: Pricing | None = None
async def update_sats_pricing():
"""Periodic task to update sats pricing for providers and overrides."""
```
#### `routstr/proxy.py` + `routstr/upstream/*`
The `x-cashu` header is handled by the proxy route and delegated to upstream providers.
### Request Proxying
#### `routstr/proxy.py`
Core proxy functionality:
```python
@proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None)
async def proxy(
request: Request, path: str, session: AsyncSession = Depends(get_session)
) -> Response | StreamingResponse:
"""Forward requests to upstream provider and charge usage."""
```
Key features:
- Streaming support
- Header preservation
- Error handling
- Usage tracking
### Database Layer
#### `routstr/core/db.py`
SQLModel definitions (selected):
```python
class ApiKey(SQLModel, table=True):
hashed_key: str = Field(primary_key=True)
balance: int
reserved_balance: int = 0
refund_address: str | None = None
key_expiry_time: int | None = None
total_spent: int = 0
total_requests: int = 0
class LightningInvoice(SQLModel, table=True):
id: str = Field(primary_key=True)
bolt11: str
amount_sats: int
status: str
class UpstreamProviderRow(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
provider_type: str
base_url: str
api_key: str
```
### 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/withdraw")
async def withdraw_balance(
request: Request, withdraw_request: WithdrawRequest
) -> dict[str, str]:
"""Generate eCash token for withdrawal"""
```
Features:
- HTML dashboard
- API key management
- Balance withdrawals
- Usage statistics
### Wallet Integration
#### `routstr/wallet.py`
Cashu wallet operations (function-based):
```python
async def recieve_token(token: str) -> tuple[int, str, str]:
"""Redeem eCash token and return amount/unit/mint."""
async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str:
"""Create eCash token for withdrawal."""
```
### Utility Modules
#### `routstr/core/logging.py`
Structured logging configuration:
```python
def setup_logging():
"""Configure JSON structured logging"""
class RequestIdFilter(logging.Filter):
"""Attach request ID to log records."""
```
#### `routstr/core/middleware.py`
HTTP middleware components:
```python
class LoggingMiddleware:
"""Log all HTTP requests/responses"""
```
#### `routstr/core/exceptions.py`
Exception handlers:
```python
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""HTTP exception handler with request ID"""
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Fallback exception handler with request ID"""
```
## Configuration Files
### `pyproject.toml`
Project metadata and dependencies:
```toml
[project]
name = "routstr"
version = "0.2.2"
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_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSession(engine, expire_on_commit=False) as session:
yield session
# Use in routes
@router.get("/items")
async def get_items(db: AsyncSession = Depends(get_session)):
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
-424
View File
@@ -1,424 +0,0 @@
# 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. Set Up Environment
Run the setup command:
```bash
make setup
```
This will:
- ✅ Install [uv](https://github.com/astral-sh/uv) if not present
- ✅ Create a virtual environment
- ✅ Install all dependencies
- ✅ Install dev tools (mypy, ruff, pytest)
- ✅ Install project in editable mode
### 3. 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
```
### 4. 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
│ ├── algorithm.py # Provider selection algorithms
│ ├── auth.py # Authentication logic
│ ├── balance.py # Balance management API
│ ├── discovery.py # Nostr discovery
│ ├── lightning.py # Lightning invoice handling
│ ├── nip91.py # Node announcement implementation
│ ├── proxy.py # Request proxying
│ ├── wallet.py # Cashu wallet integration
│ │
│ ├── core/ # Core modules
│ │ ├── admin.py # Admin dashboard API
│ │ ├── db.py # Database models (SQLModel)
│ │ ├── exceptions.py # Custom exceptions
│ │ ├── log_manager.py # Log management
│ │ ├── logging.py # Logging setup
│ │ ├── main.py # FastAPI app entry
│ │ ├── middleware.py # HTTP middleware
│ │ └── settings.py # Configuration
│ │
│ ├── payment/ # Payment processing
│ │ ├── cost_calculation.py
│ │ ├── helpers.py
│ │ ├── lnurl.py # LNURL support
│ │ ├── models.py # Model pricing
│ │ └── price.py # BTC/USD rates
│ │
│ └── upstream/ # Upstream providers
│ ├── base.py # Base provider class
│ ├── helpers.py # Shared utilities
│ ├── openai.py # OpenAI
│ ├── anthropic.py # Anthropic
│ ├── gemini.py # Google Gemini
│ ├── openrouter.py # OpenRouter
│ └── ... # More providers
├── ui/ # Admin dashboard (Next.js)
│ ├── app/ # Next.js app router
│ │ ├── page.tsx # Landing page
│ │ ├── balances/ # Balance management
│ │ ├── logs/ # Request logs viewer
│ │ ├── model/ # Model configuration
│ │ ├── providers/ # Upstream providers
│ │ ├── settings/ # Node settings
│ │ └── transactions/ # Transaction history
│ ├── components/ # React components
│ │ ├── ui/ # shadcn/ui primitives
│ │ ├── landing/ # Landing page components
│ │ └── settings/ # Settings components
│ └── lib/ # Utilities & API client
│ ├── api/ # Backend API client
│ ├── auth/ # Auth context
│ └── hooks/ # React hooks
├── tests/ # Test suite
├── migrations/ # Alembic migrations
├── scripts/ # Utility scripts
├── docs/ # Documentation
├── examples/ # Usage examples
├── Makefile # Dev commands
├── pyproject.toml # Project config
└── compose.yml # Docker setup
```
### Admin Dashboard (UI)
The admin dashboard is a Next.js app using:
- **Next.js 14** with App Router
- **shadcn/ui** for components
- **Tailwind CSS** for styling
- **pnpm** for package management
```bash
# Development
cd ui
pnpm install
pnpm dev # http://localhost:3000
# Build for production
pnpm build
```
The UI is served by the FastAPI backend at `/admin/` when built. Use `make build-ui` to build and copy to the backend.
## 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)
2. Check [open issues](https://github.com/routstr/routstr-core/issues)
3. Start with a small contribution
Happy coding! 🚀
-233
View File
@@ -1,233 +0,0 @@
# 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
- Fast feedback
- Reliable tests
- Clear failures
## Test Structure
```
tests/
├── integration/
│ ├── conftest.py
│ ├── utils.py
│ ├── test_wallet_topup.py
│ ├── test_wallet_refund.py
│ ├── test_wallet_information.py
│ ├── test_proxy_get_endpoints.py
│ ├── test_proxy_post_endpoints.py
│ └── ... more integration tests
├── unit/
│ ├── test_algorithm.py
│ ├── test_fee_consistency.py
│ ├── test_image_tokens.py
│ ├── test_logging_securityfilter.py
│ ├── test_payment_helpers.py
│ ├── test_settings.py
│ ├── test_wallet.py
│ └── ... more unit tests
└── run_integration.py
```
## Running Tests
### Make Targets
```bash
# Run all tests (unit + integration with mocks)
make test
# Unit tests only
make test-unit
# Integration tests with mocks (fast)
make test-integration
# Integration tests with Docker services
make test-integration-docker
# Fast tests only (skip slow and Docker tests)
make test-fast
# Performance tests
make test-performance
# Coverage
make test-coverage
```
### Direct pytest Commands
```bash
# Run all tests
pytest
# Run a specific test file
pytest tests/unit/test_wallet.py -v
# Run a specific test
pytest tests/unit/test_wallet.py::test_get_balance -v
# Run tests matching a pattern
pytest -k "wallet" -v
```
## Test Modes (Integration)
Integration tests support two execution modes:
- Mock mode (default): uses in-memory mocks, no Docker required
- Docker mode: uses real Docker services (Cashu mint, mock OpenAI, Nostr relay)
Use the runner script for Docker mode:
```bash
./tests/run_integration.py
```
Or manually:
```bash
docker-compose -f compose.testing.yml up -d
USE_LOCAL_SERVICES=1 pytest tests/integration/ -v
docker-compose -f compose.testing.yml down -v
```
## Test Markers
Markers are defined in `pyproject.toml`:
- `integration`
- `unit`
- `slow`
- `requires_docker`
- `requires_real_mint`
- `performance`
- `asyncio`
Examples:
```bash
# Skip slow tests
pytest -m "not slow" -v
# Run only integration tests
pytest -m "integration" -v
# Run performance tests
pytest -m "performance" -v
```
## Fixtures and Utilities
### Core Integration Fixtures
Defined in `tests/integration/conftest.py`:
- `integration_client` - Async HTTP client for the FastAPI app
- `authenticated_client` - Client with a pre-created API key
- `testmint_wallet` - Test wallet for generating Cashu tokens
- `db_snapshot` - Database state snapshot/diff helper
- `create_api_key` - Helper to create API keys for tests
- `integration_engine`, `integration_session` - Async DB engine/session
- `background_tasks_controller` - Control background tasks in tests
- `mock_upstream_server` - Mock upstream API responses
### Integration Utilities
Defined in `tests/integration/utils.py`:
- `CashuTokenGenerator`
- `ResponseValidator`
- `PerformanceValidator`
- `ConcurrencyTester`
- `DatabaseStateValidator`
- `MockServiceBuilder`
- `TestDataBuilder`
## Writing Tests
### Unit Test Example
```python
from routstr.algorithm import calculate_model_cost_score
from routstr.payment.models import Architecture, Model, Pricing
def test_calculate_model_cost_score_basic() -> None:
model = Model(
id="test-model",
name="Test test-model",
created=1234567890,
description="Test model",
context_length=8192,
architecture=Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="gpt",
instruct_type=None,
),
pricing=Pricing(
prompt=0.001,
completion=0.002,
request=0.0,
image=0.0,
web_search=0.0,
internal_reasoning=0.0,
),
)
assert calculate_model_cost_score(model) == 0.002
```
### Integration Test Example
```python
import pytest
from httpx import AsyncClient
@pytest.mark.integration
@pytest.mark.asyncio
async def test_wallet_topup(
authenticated_client: AsyncClient,
testmint_wallet: object,
db_snapshot: object,
) -> None:
await db_snapshot.capture()
token = await testmint_wallet.mint_tokens(1000)
response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": token}
)
assert response.status_code == 200
diff = await db_snapshot.diff()
assert len(diff["api_keys"]["modified"]) == 1
```
## Debugging Tips
```bash
# Show print output
pytest -s tests/unit/test_wallet.py
# Drop into debugger on failure
pytest --pdb
```
## Troubleshooting
- Docker mode failures: check `docker ps` and `docker-compose -f compose.testing.yml logs`
- Connection errors: make sure ports 3338, 3000, 8000, and 8088 are free
- Slow tests: use `pytest -m "not slow"` or `make test-fast`
## Next Steps
- See [Architecture](architecture.md)
- Read [Setup Guide](setup.md)
-44
View File
@@ -1,44 +0,0 @@
# Routstr Core Documentation
**Routstr** is a decentralized protocol for permissionless AI inference. It enables an open marketplace where anyone can buy and sell compute using **Bitcoin eCash (Cashu)**.
---
## 🐣 For Clients (Users & Builders)
If you want to use AI models in your application without accounts or KYC.
- **[Introduction](client/introduction.md)**: How the ecosystem works.
- **[Payment Flow](client/payments.md)**: Funding sessions, topping up, and refunds.
- **[Integration Guide](client/integration.md)**: Code examples for Python, JS, and cURL.
## 🦁 For Providers (Node Operators)
If you want to run a node, resell API access, or monetize hardware.
- **[Quick Start](provider/quickstart.md)**: Deploy a node in 5 minutes.
- **[Deployment](provider/deployment.md)**: Production Docker setup.
- **[Configuration](provider/configuration.md)**: Environment variables and settings.
- **[Dashboard](provider/dashboard.md)**: Managing your node visually.
- **[Pricing Strategy](provider/pricing.md)**: Setting margins and fees.
- **[Discovery](provider/discovery.md)**: Announcing your node on Nostr.
- **[Tor Support](provider/tor.md)**: Running an anonymous hidden service.
---
## 🔌 API Reference
- **[Overview](api/overview.md)**: Base URL, headers, and standards.
- **[Endpoints](api/endpoints.md)**: Full list of REST endpoints.
- **[Authentication](api/authentication.md)**: Handling API keys and tokens.
- **[Errors](api/errors.md)**: Status codes and debugging.
## 🛠️ Contributing
- **[Architecture](contributing/architecture.md)**: System design.
- **[Setup](contributing/setup.md)**: Development environment.
- **[Testing](contributing/testing.md)**: Running tests.
---
*Powered by [Cashu](https://cashu.space) and [Nostr](https://nostr.com).*
-76
View File
@@ -1,76 +0,0 @@
# Overview
Routstr is a decentralized protocol for **permissionless, private, and censorship-resistant AI inference**. It creates an open marketplace where anyone can sell llm-tokens and anyone can buy them using privacy-preserving micropayments.
By combining **Nostr** (for censorship-resistant discovery and communication) and **Cashu** (for private, instant Bitcoin eCash payments), Routstr effectively removes the "middleman" from the AI ecosystem.
## How it Works
The network consists of independent **Providers** (Sellers) and **Clients** (Buyers). There is no central server, no login, and no credit card required.
1. **Discovery (Nostr)**: Providers announce their availability, models (e.g., `gpt-4o`, `deepseek-r1`), and prices on the Nostr network.
2. **Payment (Cashu)**: Clients pay providers directly using Bitcoin eCash (Cashu tokens). These payments are untraceable and settle instantly.
3. **Inference (Proxy)**: The Provider acts as a gateway (or runs local hardware), executing the AI model and returning the result to the Client.
## Who is this for?
The documentation is split into two paths depending on your goal:
### 🐣 I want to BUILD on Routstr (Client)
You are a developer building an AI agent, a chat app, or a script, and you want access to AI models without API keys, subscriptions, or KYC.
* **No Accounts**: Just get a wallet.
* **Privacy**: Your requests are mixed with thousands of others; providers can't profile you.
* **Choice**: Switch between hundreds of providers instantly for the best price/performance.
👉 **[Go to Client Guide](client/introduction.md)**
### 🦁 I want to RUN a Node (Provider)
You have API credits (OpenAI, Anthropic, etc.) or GPU capacity and want to earn Bitcoin by selling AI access to the network.
* **Monetize API Keys**: Connect your OpenAI/Anthropic/OpenRouter accounts and earn sats on every request.
* **Monetize Hardware**: Run local models (via vLLM, Ollama) and sell access.
* **Permissionless**: No approval needed. Start the container, configure via dashboard, start earning.
!!! note "Coming Soon"
Future versions will support node-to-node routing—run a gateway without needing your own AI provider credentials.
👉 **[Go to Provider Guide](provider/quickstart.md)**
---
## Architecture
Routstr is built on a modular stack defined by the [Routstr Improvement Protocols (RIPs)](https://github.com/routstr/rips).
```mermaid
flowchart LR
subgraph Client
A[App / Agent]
end
subgraph Provider
B[Routstr Node<br/>Proxy + Auth + Billing]
end
subgraph Upstream
C[OpenAI / Anthropic<br/>vLLM / Ollama / ...]
end
A -- "Request +<br/>Cashu Token" --> B
B -- "Forward<br/>Request" --> C
C -- "Response +<br/>Usage" --> B
B -- "Response +<br/>Refund Token" --> A
```
## Why Routstr?
| Feature | Closed AI | Routstr |
| :--- | :--- | :--- |
| **Access** | Account, KYC, Credit Card | Permissionless, Bitcoin-native |
| **Privacy** | Full Logging & Tracking | Blinded Payments, Ephemeral Sessions |
| **Resilience** | Single Point of Failure | Decentralized Network |
| **Pricing** | Fixed, Monopolistic | Dynamic, Market-driven |
| **Global** | Geofenced | Borderless (Tor/I2P supported) |
-114
View File
@@ -1,114 +0,0 @@
# Advanced Pricing
Advanced pricing strategies for fine-tuned control over your revenue model.
---
## Default Behavior
By default, Routstr:
1. **Fetches costs** from your upstream provider
2. **Applies markup** using your fee settings
3. **Converts to sats** using real-time BTC price
**Formula**: `Price = Upstream Cost × Exchange Fee × Upstream Fee`
---
## Strategy 1: Fixed Per-Request
Charge a flat fee regardless of model or tokens used.
**Configure in Dashboard****Settings****Pricing**:
- Enable **Fixed Pricing**
- Set **Fixed Cost Per Request** (in sats)
**Use cases**:
- Internal tools with predictable usage
- Simple "pay once, get response" APIs
- Subscription-like tiers
---
## Strategy 2: Fixed Per-Token
Override dynamic pricing with global per-token rates.
**Configure in Dashboard****Settings****Pricing**:
| Setting | Description |
|---------|-------------|
| **Fixed Per 1K Input** | Sats per 1,000 prompt tokens |
| **Fixed Per 1K Output** | Sats per 1,000 completion tokens |
When set to non-zero values, these override model-specific pricing for all models.
---
## Strategy 3: Per-Model Custom Pricing
Set specific prices for individual models, overriding both upstream cost and global fees.
**Configure in Dashboard****Models**:
1. Click on a model (e.g., `gpt-4`)
2. Enter **Prompt Price** and **Completion Price** (USD per 1M tokens)
3. Save
**Example**: OpenAI charges $30/1M for GPT-4. Set your price to $35/1M to lock in a margin regardless of fee settings.
---
## Minimum Charge
Prevent dust transactions and spam:
| Setting | Description | Default |
|---------|-------------|---------|
| **Min Request Cost** | Minimum charge in msats | 1000 (1 sat) |
If a request's calculated cost falls below this (e.g., very short prompts), the client pays the minimum.
---
## Combining Strategies
Strategies apply in order of specificity:
1. **Per-model override** (highest priority)
2. **Fixed per-token rates**
3. **Dynamic pricing with fees** (default)
4. **Fixed per-request** (overrides all above if enabled)
**Example setup**:
- Dynamic pricing as default (10% markup)
- GPT-4 locked at $35/1M (premium model)
- Claude Haiku at 5 sats/1K tokens (budget option)
- Minimum 1 sat per request
---
## Pricing for Profit
### High-Volume Strategy
Lower margins, more clients:
- Exchange Fee: 1.002 (0.2%)
- Upstream Fee: 1.05 (5%)
### Premium Strategy
Higher margins, fewer clients:
- Exchange Fee: 1.01 (1%)
- Upstream Fee: 1.25 (25%)
### Mixed Strategy
- Cheap models (GLM-4.7-Flash, Seed-1.6): Low margin to attract volume
- Premium models (GPT-5-Pro, Claude-Opus): High margin for profit
-122
View File
@@ -1,122 +0,0 @@
# Configuration
Routstr is configured primarily through the **Admin Dashboard**. All settings persist in the database and take effect immediately—no restarts required.
For automated deployments, you can optionally pre-configure settings via environment variables.
---
## Admin Dashboard (Primary)
Access the dashboard at `/admin/` on your node.
### Upstream Providers
Connect to your AI provider(s):
| Setting | Description |
|---------|-------------|
| **Upstream URL** | API endpoint (e.g., `https://api.openai.com/v1`) |
| **API Key** | Your provider's API key |
### Node Identity
How your node appears to clients:
| Setting | Description |
|---------|-------------|
| **Name** | Display name (e.g., "Fast GPT-4 Node") |
| **Description** | Brief description of your service |
### Pricing
Control your profit margins:
| Setting | Description | Default |
|---------|-------------|---------|
| **Fixed Pricing** | Charge flat rate per request vs. per-token | Off |
| **Exchange Fee** | Buffer for BTC volatility | 1.005 (0.5%) |
| **Upstream Fee** | Your profit markup | 1.10 (10%) |
See [Pricing](pricing.md) for detailed strategies.
### Cashu Mints
Which mints to accept payments from:
| Setting | Description |
|---------|-------------|
| **Mints** | List of trusted Cashu mint URLs |
### Lightning Withdrawals
Automatic profit withdrawal:
| Setting | Description |
|---------|-------------|
| **Lightning Address** | Your LN address for withdrawals |
### Security
| Setting | Description |
|---------|-------------|
| **Admin Password** | Password for dashboard access |
### Nostr Discovery
Announce your node on the network:
| Setting | Description |
|---------|-------------|
| **Npub** | Your Nostr public key |
| **Nsec** | Your Nostr private key (for signing) |
| **Relays** | Relays to publish announcements |
See [Discovery](discovery.md) for details.
---
## Environment Variables (Optional)
Use environment variables for:
- **Automated deployments** (CI/CD, infrastructure-as-code)
- **Secrets management** (external secret stores)
- **Initial bootstrap** (set once, manage via dashboard later)
### All Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `UPSTREAM_BASE_URL` | Upstream API endpoint | — |
| `UPSTREAM_API_KEY` | Upstream API key | — |
| `ADMIN_PASSWORD` | Dashboard password | (none) |
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///keys.db` |
| `NAME` | Node display name | `ARoutstrNode` |
| `DESCRIPTION` | Node description | `A Routstr Node` |
| `NPUB` | Nostr public key (bech32) | — |
| `NSEC` | Nostr private key | — |
| `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` |
| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — |
| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
| `CORS_ORIGINS` | Allowed CORS origins | `*` |
| `RELAYS` | Nostr relays (comma-separated) | (default set) |
### Priority
Environment variables are read on startup. Dashboard settings override them and persist in the database. Once you change a setting in the dashboard, the env var is ignored for that setting.
---
## Models
Manage which AI models you offer:
1. Go to **Models** in the dashboard
2. Models are auto-discovered from your upstream
3. For each model, you can:
- **Enable/Disable** — hide expensive models you don't want to serve
- **Override pricing** — set custom per-token rates
- **Create aliases** — friendly names for models
See [Pricing](pricing.md) for per-model pricing strategies.
-208
View File
@@ -1,208 +0,0 @@
# Admin Dashboard
The Admin Dashboard is your command center for managing your Routstr provider node. Configure providers, monitor earnings, manage models, and withdraw profits—all from a web interface.
**URL**: `http://your-node:8000/admin/`
---
## Overview Tab
The main dashboard view shows your node's financial status at a glance.
### Wallet Summary
| Metric | Description |
|--------|-------------|
| **Total Wallet** | All Bitcoin currently held by your node |
| **User Balances** | Funds belonging to active client sessions |
| **Your Balance** | Your profit: `Total - User Balances` |
### Mint Status
Shows connected Cashu mints and their balances. Each mint displays:
- Connection status
- Balance in sats/msats
- Unit type
<!-- TODO: Screenshot of Overview tab -->
---
## Sessions Tab
View and manage active client sessions (API keys).
### Session List
| Column | Description |
|--------|-------------|
| **Hashed Key** | Privacy-preserving identifier (not the actual key) |
| **Balance** | Remaining funds in the session |
| **Spent** | Total amount spent by this session |
| **Requests** | Number of API calls made |
| **Created** | When the session was created |
| **Expires** | Auto-expiry time (if set) |
### Actions
- **View Details** — See full session history
- **Revoke** — Terminate a session (remaining balance returns to your wallet)
<!-- TODO: Screenshot of Sessions tab -->
---
## Models Tab
Manage which AI models you offer to clients.
### Model List
Shows all models available from your upstream provider(s):
| Column | Description |
|--------|-------------|
| **Model ID** | The model identifier (e.g., `gpt-4o`) |
| **Enabled** | Whether clients can use this model |
| **Input Price** | Cost per 1M input tokens (USD) |
| **Output Price** | Cost per 1M output tokens (USD) |
| **Custom** | Whether pricing is overridden |
### Actions
- **Import Models** — Fetch latest model list from upstream
- **Enable/Disable** — Toggle model availability
- **Edit Pricing** — Override default pricing for a model
- **Create Alias** — Map a friendly name to a model
### Editing a Model
Click on any model to configure:
| Field | Description |
|-------|-------------|
| **Enabled** | Show this model to clients |
| **Prompt Price** | Custom price per 1M input tokens (USD) |
| **Completion Price** | Custom price per 1M output tokens (USD) |
| **Alias** | Alternative name for this model |
<!-- TODO: Screenshot of Models tab -->
<!-- TODO: Screenshot of Model edit modal -->
---
## Settings Tab
Configure all node settings. Changes take effect immediately.
### Upstream
Connect to your AI provider:
| Field | Description |
|-------|-------------|
| **Base URL** | API endpoint (e.g., `https://api.openai.com/v1`) |
| **API Key** | Your provider's secret key |
<!-- TODO: Screenshot of Upstream settings -->
### Node Identity
| Field | Description |
|-------|-------------|
| **Name** | Public display name |
| **Description** | Brief description of your service |
| **Npub** | Nostr public key for discovery |
### Pricing
| Field | Description |
|-------|-------------|
| **Fixed Pricing** | Toggle flat-rate vs. per-token pricing |
| **Fixed Cost** | Sats per request (when fixed pricing enabled) |
| **Exchange Fee** | Multiplier for BTC volatility buffer |
| **Upstream Fee** | Your profit margin multiplier |
**Example**: With Exchange Fee `1.005` and Upstream Fee `1.10`:
- Upstream cost: $30/1M tokens
- Your price: $30 × 1.005 × 1.10 = $33.17/1M tokens
### Cashu Mints
Manage which mints you accept payments from:
- **Add Mint** — Enter a mint URL
- **Remove Mint** — Stop accepting from a mint
- **Test Connection** — Verify mint is reachable
<!-- TODO: Screenshot of Mints settings -->
### Lightning
| Field | Description |
|-------|-------------|
| **Lightning Address** | Your LN address for automatic withdrawals |
### Nostr Discovery
| Field | Description |
|-------|-------------|
| **Nsec** | Private key for signing announcements |
| **Relays** | Where to publish your node advertisement |
### Security
| Field | Description |
|-------|-------------|
| **Admin Password** | Password for dashboard access |
!!! warning "Set a Password"
The dashboard has no password by default. Always set one for production nodes.
<!-- TODO: Screenshot of Security settings -->
---
## Withdraw Tab
Withdraw your profits to a Lightning wallet.
### Steps
1. **Select Mint** — Choose which mint to withdraw from
2. **Enter Amount** — How many sats to withdraw
3. **Generate Token** — Creates a Cashu token
4. **Redeem** — Paste the token into your Cashu wallet and melt to Lightning
<!-- TODO: Screenshot of Withdraw tab -->
### Alternative: Lightning Address
If you've configured a Lightning Address in Settings, profits can be automatically swept to your wallet (coming soon).
---
## Logs Tab
View node logs for debugging without SSH access.
### Features
- **Filter by Level** — Error, Warning, Info, Debug
- **Search** — Find specific entries
- **Time Range** — View logs from specific periods
- **Auto-refresh** — Watch logs in real-time
### Common Log Entries
| Entry | Meaning |
|-------|---------|
| `Upstream request failed` | Problem connecting to your AI provider |
| `Invalid token` | Client sent an invalid Cashu token |
| `Session expired` | API key reached its time limit |
| `Insufficient balance` | Client ran out of funds mid-request |
<!-- TODO: Screenshot of Logs tab -->
-196
View File
@@ -1,196 +0,0 @@
# Deployment
Production deployment guide for Routstr Provider nodes.
## Docker Compose (Recommended)
For production, use Docker Compose with persistent storage and optional Tor support.
### Basic Setup
Create a `compose.yml`:
```yaml
services:
routstr:
image: ghcr.io/routstr/proxy:latest
container_name: routstr
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- ./data:/app/data
- ./logs:/app/logs
```
Start the node:
```bash
docker compose up -d
```
Then configure everything via the [Admin Dashboard](http://localhost:8000/admin/).
---
## With Tor (Anonymous Access)
Add Tor to serve your node as a hidden service—no port forwarding needed.
```yaml
services:
routstr:
image: ghcr.io/routstr/proxy:latest
container_name: routstr
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- ./data:/app/data
- ./logs:/app/logs
environment:
- TOR_PROXY_URL=socks5://tor:9050
depends_on:
- tor
tor:
image: ghcr.io/hundehausen/tor-hidden-service:latest
container_name: tor
restart: unless-stopped
volumes:
- ./tor-data:/var/lib/tor
environment:
- HS_ROUTER=routstr:8000:80
```
After starting, find your `.onion` address:
```bash
docker exec tor cat /var/lib/tor/hidden_service/hostname
```
See [Tor Support](tor.md) for details.
---
## Pre-Configuration (Optional)
While everything can be configured via the dashboard, you can pre-configure settings with environment variables for automated deployments.
### Using Environment Variables
```yaml
services:
routstr:
image: ghcr.io/routstr/proxy:latest
environment:
# Pre-configure upstream (optional)
- UPSTREAM_BASE_URL=https://api.openai.com/v1
- UPSTREAM_API_KEY=sk-proj-...
# Secure the dashboard (recommended)
- ADMIN_PASSWORD=your-secure-password
# Node identity
- NAME=My Provider Node
- DESCRIPTION=Fast GPT-4 access via Lightning
# Lightning withdrawals
- RECEIVE_LN_ADDRESS=me@walletofsatoshi.com
volumes:
- ./data:/app/data
```
### Using an .env File
```yaml
services:
routstr:
image: ghcr.io/routstr/proxy:latest
env_file:
- .env
volumes:
- ./data:/app/data
```
Example `.env`:
```bash
UPSTREAM_BASE_URL=https://api.openai.com/v1
UPSTREAM_API_KEY=sk-proj-...
ADMIN_PASSWORD=change-me
NAME=My Provider Node
RECEIVE_LN_ADDRESS=me@walletofsatoshi.com
```
See [Configuration](configuration.md) for all available options.
---
## Persistence
Routstr stores all data in `/app/data`:
| Path | Contents |
|------|----------|
| `keys.db` | SQLite database (settings, API keys, sessions) |
| `.wallet/` | Cashu wallet data (your Bitcoin!) |
!!! warning "Back Up Your Data"
The `./data` volume contains your wallet. Losing it means losing funds. Back up regularly.
---
## Reverse Proxy (Optional)
For custom domains and SSL, use a reverse proxy like Caddy or nginx.
### Caddy Example
```
api.yournode.com {
reverse_proxy localhost:8000
}
```
### nginx Example
```nginx
server {
listen 443 ssl;
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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
---
## Updates
Pull the latest image and restart:
```bash
docker compose pull
docker compose up -d
```
---
## Building from Source
```bash
git clone https://github.com/routstr/routstr-core.git
cd routstr-core
docker build -t routstr-local .
```
-95
View File
@@ -1,95 +0,0 @@
# Discovery
Routstr uses **Nostr** as a decentralized directory for service discovery. Your node announces its presence, models, and pricing on Nostr relays, allowing clients to find you without a central server.
---
## How It Works
1. **Provider Advertisement (Kind 38421)**: Your node periodically publishes an event with its URL, models, and pricing
2. **Client Discovery**: Clients query relays for these events to find suitable providers
---
## Configuration
Configure discovery in **Dashboard****Settings****Nostr**.
### Required Settings
| Field | Description |
|-------|-------------|
| **Npub** | Your node's public identity (clients use this to verify your node) |
| **Nsec** | Your node's private key (used to sign advertisements) |
| **Relays** | Where to publish your announcements |
### Default Relays
If not configured, Routstr publishes to:
- `wss://relay.damus.io`
- `wss://relay.nostr.band`
- `wss://nos.lol`
---
## Advertisement Format
Your node publishes events like:
```json
{
"kind": 38421,
"content": {
"name": "My Routstr Node",
"description": "Fast GPT-4 access via Lightning",
"endpoints": {
"http": "https://api.mynode.com",
"onion": "http://xyz...onion"
},
"models": ["gpt-4", "claude-3-opus"],
"pricing": { ... }
},
"tags": [
["d", "routstr-provider"],
["g", "US"]
]
}
```
---
## Tor Integration
If you're running with Tor (see [Tor Support](tor.md)), your `.onion` address is automatically included in announcements. This allows clients to connect anonymously.
---
## Verify Your Announcements
Check if your node is broadcasting:
1. Copy your `Npub`
2. Search on [Nostr.band](https://nostr.band) or [Primal](https://primal.net)
3. Look for Kind 38421 events
---
## Generating Keys
If you don't have a Nostr identity:
1. Use any Nostr client (e.g., [Primal](https://primal.net), [Damus](https://damus.io))
2. Create an account
3. Export your keys (npub and nsec)
4. Enter them in the dashboard
Or generate keys programmatically:
```python
from nostr_sdk import Keys
keys = Keys.generate()
print(f"npub: {keys.public_key().to_bech32()}")
print(f"nsec: {keys.secret_key().to_bech32()}")
```
-91
View File
@@ -1,91 +0,0 @@
# Pricing
Routstr's pricing engine lets you act as a retailer of AI compute. You pay upstream providers (OpenAI, Anthropic, etc.) at their rates and sell to clients with your markup.
---
## Pricing Strategies
Configure these in **Dashboard****Settings****Pricing**.
### Dynamic Pricing (Default)
Passes through upstream costs plus your percentage markup.
**Formula**: `Client Price = Upstream Cost × Exchange Fee × Upstream Fee`
| Setting | Description | Default |
|---------|-------------|---------|
| **Exchange Fee** | Buffer for BTC price volatility | 1.005 (0.5%) |
| **Upstream Fee** | Your profit margin | 1.10 (10%) |
**Example**: GPT-4 costs $30/1M tokens from OpenAI. With default settings:
- Price: $30 × 1.005 × 1.10 = $33.17/1M tokens
- At $60k BTC: ~55,000 sats/1M tokens
### Fixed Pricing
Charge a flat rate per request, regardless of model or token count.
| Setting | Description |
|---------|-------------|
| **Fixed Pricing** | Enable flat-rate mode |
| **Fixed Cost** | Sats per request |
**Best for**: Simple proxies, internal tools, or subscription-like access.
---
## Per-Model Pricing
Override pricing for specific models in **Dashboard****Models**.
1. Click on a model
2. Enter custom **Prompt Price** and **Completion Price** (USD per 1M tokens)
3. Save
This overrides both the upstream cost and your global markup for that model.
**Example**: Lock GPT-4 at $35/1M tokens regardless of OpenAI's actual rate or your fee settings.
---
## Token-Based Overrides
Set global fixed rates per token (overrides dynamic pricing for all models):
| Setting | Description |
|---------|-------------|
| **Fixed Per 1K Input** | Sats per 1,000 prompt tokens |
| **Fixed Per 1K Output** | Sats per 1,000 completion tokens |
---
## Minimum Charge
Prevent spam with a minimum cost per request:
| Setting | Description | Default |
|---------|-------------|---------|
| **Min Request Cost** | Minimum charge in msats | 1000 (1 sat) |
If a request's calculated cost is lower than this, the client pays the minimum instead.
---
## Cost Tracking
Routstr tracks balances in **millisats (msats)** for precision with cheap models.
- 1 sat = 1,000 msats
- API responses include cost in msats
- Lightning withdrawals round down to whole sats
### Client Verification (RIP-05)
Clients can verify charges:
1. Fetch `/v1/models` for your advertised rates
2. Calculate expected cost from token counts
3. Compare to `x-routstr-cost` response header
-99
View File
@@ -1,99 +0,0 @@
# Quick Start
Start earning Bitcoin by selling AI access in under 5 minutes.
## What You'll Build
A **Routstr Provider Node** acts as a gateway that:
1. **Connects** to upstream AI providers (OpenAI, Anthropic, OpenRouter, etc.)
2. **Accepts** Bitcoin payments via Cashu eCash
3. **Serves** AI requests to clients on the network
You bring the API keys, Routstr handles the billing, payments, and client management.
!!! tip "Future: Node-to-Node Routing"
In future versions, you'll be able to run a node that connects to other Routstr nodes—eliminating the need to configure upstream providers yourself. For now, you'll need your own API credentials.
---
## Prerequisites
- [Docker](https://docs.docker.com/get-docker/) installed
- API credentials from at least one AI provider (OpenAI, Anthropic, OpenRouter, etc.)
---
## 1. Start the Node
```bash
docker run -d \
--name routstr \
-p 8000:8000 \
-v routstr-data:/app/data \
ghcr.io/routstr/proxy:latest
```
Verify it's running:
```bash
curl http://localhost:8000/v1/info
```
---
## 2. Configure via Dashboard
Open the **Admin Dashboard** at [http://localhost:8000/admin/](http://localhost:8000/admin/).
!!! note "Default Access"
The dashboard has no password by default. Set one immediately in Settings for production use.
### Connect Your AI Providers
1. Navigate to **Settings****Upstream**
2. Enter your upstream URL (e.g., `https://api.openai.com/v1`)
3. Enter your API key
4. Save
### Set Your Profit Margin
1. Go to **Settings****Pricing**
2. Configure your markup (default is 10%)
3. Optionally set a fixed price per request instead
### Secure the Dashboard
1. Go to **Settings****Admin**
2. Set a strong password
3. Save and re-login
---
## 3. Start Earning
Once configured, your node is live. Clients pay you in Bitcoin (via Cashu tokens) for every AI request.
### Monitor Your Earnings
The dashboard shows:
- **Total Wallet**: All Bitcoin held by your node
- **User Balances**: Funds belonging to active client sessions
- **Your Balance**: Your profit (`Total - User Balances`)
### Withdraw Profits
1. Go to **Withdraw** in the dashboard
2. Select amount and mint
3. Generate a Cashu token
4. Redeem to your Lightning wallet
---
## Next Steps
- **[Deployment](deployment.md)**: Production setup with Docker Compose and Tor
- **[Dashboard Guide](dashboard.md)**: Full reference for all dashboard features
- **[Pricing](pricing.md)**: Configure pricing strategies and per-model overrides
- **[Discovery](discovery.md)**: Announce your node on Nostr for clients to find you
-50
View File
@@ -1,50 +0,0 @@
# Tor Support
Running Routstr as a **Tor Hidden Service** allows you to offer API access anonymously and bypass NAT/firewalls without port forwarding.
## Automatic Setup (Docker)
The standard `compose.yml` includes a Tor container pre-configured to serve your node.
1. **Start the stack**: `docker compose up -d`
2. **Wait**: Tor takes about 30 seconds to generate keys and bootstrap.
3. **Find your address**:
```bash
docker exec tor cat /var/lib/tor/hidden_service/hostname
```
Output: `v2xyz...longaddress.onion`
Routstr will automatically detect this address (via the `discover_onion_url_from_tor` logic) and include it in:
- The `/v1/info` endpoint.
- Nostr announcements (RIP-02).
## Manual Setup
If you are running outside Docker or managing Tor yourself:
1. **Install Tor**: `sudo apt install tor`
2. **Edit `torrc`**:
```
HiddenServiceDir /var/lib/tor/routstr/
HiddenServicePort 80 127.0.0.1:8000
```
3. **Restart Tor**: `sudo systemctl restart tor`
4. **Get Address**: `sudo cat /var/lib/tor/routstr/hostname`
5. **Configure Routstr**:
Set `ONION_URL=http://youraddress.onion` in your `.env` file so the node knows its own address.
## Client Usage
Clients connecting to your `.onion` address must route traffic through SOCKS5.
**Python Example:**
```python
import httpx
from openai import OpenAI
client = OpenAI(
base_url="http://youraddress.onion/v1",
api_key="sk-...",
http_client=httpx.Client(proxy="socks5://127.0.0.1:9050")
)
```
-4
View File
@@ -1,4 +0,0 @@
mkdocs>=1.5.3
mkdocs-material>=9.5.0
mkdocs-mermaid2-plugin>=1.1.1
pymdown-extensions>=10.5
+37
View File
@@ -0,0 +1,37 @@
import os
import openai
client = openai.OpenAI(
api_key=os.environ["CASHU_TOKEN"],
base_url=os.environ.get("ROUTSTR_API_URL", "https://api.routstr.com/v1"),
# base_url="http://roustrjfsdgfiueghsklchg.onion/v1",
# client=httpx.AsyncClient(
# proxies={"http": "socks5://localhost:9050"},
# ), # to use onion proxy (tor)
)
history: list = []
def chat() -> None:
while True:
user_msg = {"role": "user", "content": input("\nYou: ")}
history.append(user_msg)
ai_msg = {"role": "assistant", "content": ""}
for chunk in client.chat.completions.create(
model=os.environ.get("MODEL", "openai/gpt-4o-mini"),
messages=history,
stream=True,
):
if len(chunk.choices) > 0:
content = chunk.choices[0].delta.content
if content is not None:
ai_msg["content"] += content
print(content, end="", flush=True)
print()
history.append(ai_msg)
if __name__ == "__main__":
chat()
-11
View File
@@ -1,11 +0,0 @@
import os
import httpx
# Use your Cashu token or API key as the Bearer token,
# cashu token is hashed on the server and acts as an Temporary API key
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
resp = httpx.get(f"{base_url}/balance/info", headers=headers)
print(resp.json())
-15
View File
@@ -1,15 +0,0 @@
import os
import httpx
# Send a Cashu token to the /create endpoint to get a persistent API key
token = os.environ.get("TOKEN")
if not token:
print("Please set TOKEN environment variable with a Cashu token")
exit(1)
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
resp = httpx.get(f"{base_url}/balance/create", params={"initial_balance_token": token})
print(resp.json())
-12
View File
@@ -1,12 +0,0 @@
import os
import httpx
# Use your Cashu token or API key as the Bearer token
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
resp = httpx.post(f"{base_url}/balance/refund", headers=headers)
print("Refund successful!")
print(resp.json())
-16
View File
@@ -1,16 +0,0 @@
import os
import httpx
# Use your Cashu token or API key as the Bearer token
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
# The Cashu token to top up with
cashu_token = input("Enter Cashu token to top up: ")
resp = httpx.post(
f"{base_url}/balance/topup", headers=headers, json={"cashu_token": cashu_token}
)
print(resp.json())
-15
View File
@@ -1,15 +0,0 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
response = client.chat.completions.create(
model=os.environ.get("MODEL", "gpt-5-nano"),
messages=[{"role": "user", "content": "Hello!"}],
)
print(response.choices[0].message.content)
-45
View File
@@ -1,45 +0,0 @@
import json
import sys
import httpx
def create_child_keys(base_url: str, api_key: str, count: int = 3) -> list[str]:
headers = {"Authorization": f"Bearer {api_key}"}
print(f"Requesting {count} child keys from {base_url}...")
child_keys = []
for i in range(count):
try:
response = httpx.post(f"{base_url}/v1/balance/child-key", headers=headers)
if response.status_code == 200:
data = response.json()
child_keys.append(data["api_key"])
print(
f" [{i + 1}] Created: {data['api_key']} (Cost: {data['cost_msats']} msats)"
)
else:
print(f" [{i + 1}] Failed: {response.status_code} - {response.text}")
except Exception as e:
print(f" [{i + 1}] Error: {str(e)}")
return child_keys
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python create_child_keys.py <api_key_or_cashu_token> [base_url]")
sys.exit(1)
auth_key = sys.argv[1]
base_url = sys.argv[2] if len(sys.argv) > 2 else "http://localhost:8000"
keys = create_child_keys(base_url, auth_key)
if keys:
print("\nSuccessfully created child keys:")
print(json.dumps(keys, indent=2))
else:
print("\nNo child keys were created.")
-19
View File
@@ -1,19 +0,0 @@
import os
import httpx
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN", ""),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
for model in client.models.list():
print(model.id)
# OR
models = httpx.get(
f"{client.base_url}/v1/models",
headers={"Authorization": f"Bearer {client.api_key}"},
).json()
-31
View File
@@ -1,31 +0,0 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
conversation = [] # type: ignore
# First turn
response1 = client.responses.create( # type: ignore
model="o4-mini",
input="Hi, my name is Alice.",
conversation=conversation,
)
print("Response 1:", response1.output)
# Note: The 'conversation' parameter might need to be constructed differently
# depending on exact SDK/API spec. Typically, you pass back the previous turn's data.
# Assuming the SDK manages or returns a conversation object/ID:
# conversation.append(response1)
# Second turn - demonstrating intent, actual implementation depends on strict API spec
# response2 = client.responses.create(
# model="openai/gpt-4o-mini",
# input="What is my name?",
# conversation=conversation,
# )
# print("Response 2:", response2.output)
-17
View File
@@ -1,17 +0,0 @@
import os
from openai import OpenAI
# The OpenAI SDK handles the 'responses' endpoint if it's updated to the latest version
# and the base_url points to a compatible proxy like Routstr.
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
response = client.responses.create(
model="gpt-5-mini",
input="Tell me a three sentence bedtime story about a unicorn.",
)
print(response.output)
-20
View File
@@ -1,20 +0,0 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
stream = client.responses.create(
model="claude-4.5-sonnet",
input="Write a short poem about rust.",
stream=True,
)
for event in stream:
# Note: Depending on the SDK version and response structure,
# you might access event.output_delta or similar fields
print(event, end="", flush=True)
print()
-16
View File
@@ -1,16 +0,0 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
response = client.responses.create(
model="gpt-5-mini",
input="What is the latest news about AI?",
tools=[{"type": "web_search"}], # type: ignore
)
print(response.output)
-28
View File
@@ -1,28 +0,0 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
messages = []
while True:
messages.append({"role": "user", "content": input("\nYou: ")})
stream = client.chat.completions.create(
model=os.environ.get("MODEL", "gpt-5.1-mini"),
messages=messages, # type: ignore
stream=True,
)
print("AI: ", end="")
response_content = ""
for chunk in stream:
if content := chunk.choices[0].delta.content: # type: ignore
print(content, end="", flush=True)
response_content += content
print()
messages.append({"role": "assistant", "content": response_content})
-20
View File
@@ -1,20 +0,0 @@
import os
import httpx
from openai import OpenAI
# Requires `pip install "httpx[socks]"` and a running Tor proxy on port 9050
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("ONION_URL", "http://roustrjfsdgfiueghsklchg.onion/v1"),
http_client=httpx.Client(proxies="socks5://localhost:9050"),
)
print(
client.chat.completions.create(
model="openai/gpt-4o-mini",
messages=[{"role": "user", "content": "Hello from Tor!"}],
)
.choices[0]
.message.content
)
-73
View File
@@ -1,73 +0,0 @@
import asyncio
import pathlib
import sys
# from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
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]))
config = context.config
if config.config_file_name is None:
raise ValueError("config_file_name is None")
# Skip loading alembic's logging configuration to preserve our custom logging
# fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", DATABASE_URL)
target_metadata = SQLModel.metadata
def run_migrations_offline() -> None:
context.configure(
url=DATABASE_URL,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection, target_metadata=target_metadata, compare_type=True
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = create_async_engine(DATABASE_URL, poolclass=pool.NullPool)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
# Check if we're already in an event loop (e.g., being called from FastAPI)
try:
loop = asyncio.get_running_loop()
# If we're in an existing loop, create a new thread to run migrations
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, run_migrations_online())
future.result()
except RuntimeError:
# No event loop running, we can use asyncio.run directly
asyncio.run(run_migrations_online())
-24
View File
@@ -1,24 +0,0 @@
# -*- coding: utf-8 -*-
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -1,30 +0,0 @@
"""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 ###
@@ -1,31 +0,0 @@
"""add mint field
Revision ID: 7bc4e8b02b9d
Revises: f6ce1348e266
Create Date: 2025-08-09 13:48:40.648729
"""
import sqlalchemy as sa
from alembic import op
from sqlmodel.sql import sqltypes
# revision identifiers, used by Alembic.
revision = "7bc4e8b02b9d"
down_revision = "f6ce1348e266"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"api_keys",
sa.Column("mint_url", sqltypes.AutoString(), nullable=True),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("api_keys", "mint_url")
# ### end Alembic commands ###
@@ -1,38 +0,0 @@
"""add mint+currency refund details
Revision ID: 898f00ea481e
Revises: 7bc4e8b02b9d
Create Date: 2025-08-13 16:45:42.148314
"""
import sqlalchemy as sa
from alembic import op
from sqlmodel.sql import sqltypes
# revision identifiers, used by Alembic.
revision = "898f00ea481e"
down_revision = "7bc4e8b02b9d"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"api_keys",
sa.Column("refund_mint_url", sqltypes.AutoString(), nullable=True),
)
op.add_column(
"api_keys",
sa.Column("refund_currency", sqltypes.AutoString(), nullable=True),
)
op.drop_column("api_keys", "mint_url")
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("api_keys", sa.Column("mint_url", sa.VARCHAR(), nullable=True))
op.drop_column("api_keys", "refund_currency")
op.drop_column("api_keys", "refund_mint_url")
# ### end Alembic commands ###
@@ -1,64 +0,0 @@
"""change models to composite primary key (id, upstream_provider_id)
Revision ID: a1a1a1a1a1a1
Revises: f7a8b9c0d1e2
Create Date: 2025-10-20 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "a1a1a1a1a1a1"
down_revision = "f7a8b9c0d1e2"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if "models" in inspector.get_table_names():
op.drop_table("models")
op.create_table(
"models",
sa.Column("id", sa.String(), nullable=False),
sa.Column("upstream_provider_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("created", sa.Integer(), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("context_length", sa.Integer(), nullable=False),
sa.Column("architecture", sa.Text(), nullable=False),
sa.Column("pricing", sa.Text(), nullable=False),
sa.Column("sats_pricing", sa.Text(), nullable=True),
sa.Column("per_request_limits", sa.Text(), nullable=True),
sa.Column("top_provider", sa.Text(), nullable=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
sa.PrimaryKeyConstraint("id", "upstream_provider_id"),
sa.ForeignKeyConstraint(
["upstream_provider_id"], ["upstream_providers.id"], ondelete="CASCADE"
),
)
def downgrade() -> None:
op.drop_table("models")
op.create_table(
"models",
sa.Column("id", sa.String(), primary_key=True, nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("created", sa.Integer(), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("context_length", sa.Integer(), nullable=False),
sa.Column("architecture", sa.Text(), nullable=False),
sa.Column("pricing", sa.Text(), nullable=False),
sa.Column("sats_pricing", sa.Text(), nullable=True),
sa.Column("per_request_limits", sa.Text(), nullable=True),
sa.Column("top_provider", sa.Text(), nullable=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("upstream_provider_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(["upstream_provider_id"], ["upstream_providers.id"]),
)
@@ -1,35 +0,0 @@
"""add settings table
Revision ID: a1b2c3d4e5f6
Revises: 042f6b77d69d
Create Date: 2025-09-06 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "a1b2c3d4e5f6"
down_revision = "042f6b77d69d"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"settings",
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
sa.Column("data", sa.Text(), nullable=False),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=True,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
)
def downgrade() -> None:
op.drop_table("settings")
-42
View File
@@ -1,42 +0,0 @@
"""
Revision ID: a86e5348850b
Revises: b9667ffc5701
Create Date: 2026-01-10 18:57:48.475781
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "a86e5348850b"
down_revision = "b9667ffc5701"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Use batch_alter_table for SQLite compatibility
with op.batch_alter_table("api_keys", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"parent_key_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True
)
)
batch_op.create_index(
batch_op.f("ix_api_keys_parent_key_hash"), ["parent_key_hash"], unique=False
)
batch_op.create_foreign_key(
"fk_api_keys_parent_key_hash",
"api_keys",
["parent_key_hash"],
["hashed_key"],
)
def downgrade() -> None:
with op.batch_alter_table("api_keys", schema=None) as batch_op:
batch_op.drop_constraint("fk_api_keys_parent_key_hash", type_="foreignkey")
batch_op.drop_index(batch_op.f("ix_api_keys_parent_key_hash"))
batch_op.drop_column("parent_key_hash")
@@ -1,37 +0,0 @@
"""alias-ids
Revision ID: b9667ffc5701
Revises: lightning_invoices
Create Date: 2025-12-25 19:30:44.673350
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "b9667ffc5701"
down_revision = "lightning_invoices"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic ###
op.add_column(
"models",
sa.Column("canonical_slug", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
)
op.add_column(
"models",
sa.Column("alias_ids", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("models", "alias_ids")
op.drop_column("models", "canonical_slug")
# ### end Alembic commands ###
@@ -1,37 +0,0 @@
"""create models table
Revision ID: c0ffee123456
Revises: a1b2c3d4e5f6
Create Date: 2025-09-10 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "c0ffee123456"
down_revision = "a1b2c3d4e5f6"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"models",
sa.Column("id", sa.String(), primary_key=True, nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("created", sa.Integer(), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("context_length", sa.Integer(), nullable=False),
sa.Column("architecture", sa.Text(), nullable=False),
sa.Column("pricing", sa.Text(), nullable=False),
sa.Column("sats_pricing", sa.Text(), nullable=True),
sa.Column("per_request_limits", sa.Text(), nullable=True),
sa.Column("top_provider", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_table("models")
@@ -1,118 +0,0 @@
"""make upstream provider base_url + api_key unique
Revision ID: c2d3e4f5a6b7
Revises: a86e5348850b
Create Date: 2026-01-25 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "c2d3e4f5a6b7"
down_revision = "a86e5348850b"
branch_labels = None
depends_on = None
def _recreate_table_sqlite(add_base_url_unique: bool) -> None:
conn = op.get_bind()
existing_tables = {
row[0]
for row in conn.exec_driver_sql(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
}
if "upstream_providers_old" in existing_tables:
if "upstream_providers" in existing_tables:
op.drop_table("upstream_providers_old")
else:
op.execute(
"ALTER TABLE upstream_providers_old RENAME TO upstream_providers"
)
existing_tables.add("upstream_providers")
if "upstream_providers" not in existing_tables:
return
constraints = [
sa.UniqueConstraint(
"base_url",
"api_key",
name="uq_upstream_providers_base_url_api_key",
)
]
if add_base_url_unique:
constraints.append(
sa.UniqueConstraint("base_url", name="uq_upstream_providers_base_url")
)
op.execute("ALTER TABLE upstream_providers RENAME TO upstream_providers_old")
op.create_table(
"upstream_providers",
sa.Column(
"id", sa.Integer(), primary_key=True, nullable=False, autoincrement=True
),
sa.Column("provider_type", sa.String(), nullable=False),
sa.Column("base_url", sa.String(), nullable=False),
sa.Column("api_key", sa.String(), nullable=False),
sa.Column("api_version", sa.String(), nullable=True),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("provider_fee", sa.Float(), nullable=False, server_default="1.01"),
*constraints,
)
op.execute(
"INSERT INTO upstream_providers (id, provider_type, base_url, api_key, api_version, enabled, provider_fee) "
"SELECT id, provider_type, base_url, api_key, api_version, enabled, provider_fee "
"FROM upstream_providers_old"
)
op.drop_table("upstream_providers_old")
def upgrade() -> None:
conn = op.get_bind()
if conn.dialect.name == "sqlite":
_recreate_table_sqlite(add_base_url_unique=False)
return
inspector = sa.inspect(conn)
for constraint in inspector.get_unique_constraints("upstream_providers"):
name = constraint.get("name")
if constraint.get("column_names") == ["base_url"] and name:
op.drop_constraint(
name,
"upstream_providers",
type_="unique",
)
index_names = {idx["name"] for idx in inspector.get_indexes("upstream_providers")}
if "ix_upstream_providers_base_url" in index_names:
op.drop_index("ix_upstream_providers_base_url", table_name="upstream_providers")
op.create_unique_constraint(
"uq_upstream_providers_base_url_api_key",
"upstream_providers",
["base_url", "api_key"],
)
def downgrade() -> None:
conn = op.get_bind()
if conn.dialect.name == "sqlite":
_recreate_table_sqlite(add_base_url_unique=True)
return
op.drop_constraint(
"uq_upstream_providers_base_url_api_key",
"upstream_providers",
type_="unique",
)
op.create_unique_constraint(
"uq_upstream_providers_base_url",
"upstream_providers",
["base_url"],
)
op.create_index(
"ix_upstream_providers_base_url",
"upstream_providers",
["base_url"],
unique=True,
)
@@ -1,45 +0,0 @@
"""create upstream_providers table
Revision ID: d1e2f3a4b5c6
Revises: c0ffee123456
Create Date: 2025-10-09 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "d1e2f3a4b5c6"
down_revision = "c0ffee123456"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if "upstream_providers" not in inspector.get_table_names():
op.create_table(
"upstream_providers",
sa.Column(
"id", sa.Integer(), primary_key=True, nullable=False, autoincrement=True
),
sa.Column("provider_type", sa.String(), nullable=False),
sa.Column("base_url", sa.String(), nullable=False, unique=True),
sa.Column("api_key", sa.String(), nullable=False),
sa.Column("api_version", sa.String(), nullable=True),
sa.Column("enabled", sa.Boolean(), nullable=False, default=True),
)
op.create_index(
"ix_upstream_providers_base_url",
"upstream_providers",
["base_url"],
unique=True,
)
def downgrade() -> None:
op.drop_index("ix_upstream_providers_base_url", "upstream_providers")
op.drop_table("upstream_providers")
@@ -1,53 +0,0 @@
"""add upstream_provider and enabled to models
Revision ID: e1f2a3b4c5d6
Revises: d1e2f3a4b5c6
Create Date: 2025-10-13 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "e1f2a3b4c5d6"
down_revision = "d1e2f3a4b5c6"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_table("models")
op.create_table(
"models",
sa.Column("id", sa.String(), primary_key=True, nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("created", sa.Integer(), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("context_length", sa.Integer(), nullable=False),
sa.Column("architecture", sa.Text(), nullable=False),
sa.Column("pricing", sa.Text(), nullable=False),
sa.Column("sats_pricing", sa.Text(), nullable=True),
sa.Column("per_request_limits", sa.Text(), nullable=True),
sa.Column("top_provider", sa.Text(), nullable=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("upstream_provider_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(["upstream_provider_id"], ["upstream_providers.id"]),
)
def downgrade() -> None:
op.drop_table("models")
op.create_table(
"models",
sa.Column("id", sa.String(), primary_key=True, nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("created", sa.Integer(), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("context_length", sa.Integer(), nullable=False),
sa.Column("architecture", sa.Text(), nullable=False),
sa.Column("pricing", sa.Text(), nullable=False),
sa.Column("sats_pricing", sa.Text(), nullable=True),
sa.Column("per_request_limits", sa.Text(), nullable=True),
sa.Column("top_provider", sa.Text(), nullable=True),
)
-40
View File
@@ -1,40 +0,0 @@
"""init
Revision ID: f6ce1348e266
Revises:
Create Date: 2025-08-09 13:28:38.537652
"""
import sqlalchemy as sa
from alembic import op
from sqlmodel.sql import sqltypes
# revision identifiers, used by Alembic.
revision = "f6ce1348e266"
down_revision = None
branch_labels = None
depends_on = None
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", sqltypes.AutoString(), nullable=False),
sa.Column("balance", sa.Integer(), nullable=False),
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),
sa.PrimaryKeyConstraint("hashed_key"),
)
def downgrade() -> None:
# Only drop the table if it exists
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
if "api_keys" in tables:
op.drop_table("api_keys")
@@ -1,27 +0,0 @@
"""add provider_fee to upstream_providers
Revision ID: f7a8b9c0d1e2
Revises: e1f2a3b4c5d6
Create Date: 2025-10-13 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "f7a8b9c0d1e2"
down_revision = "e1f2a3b4c5d6"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"upstream_providers",
sa.Column("provider_fee", sa.Float(), nullable=False, server_default="1.01"),
)
def downgrade() -> None:
op.drop_column("upstream_providers", "provider_fee")
@@ -1,39 +0,0 @@
"""Add lightning_invoices table
Revision ID: lightning_invoices
Revises: a1a1a1a1a1a1
Create Date: 2025-12-10 21:00:00.000000
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
revision = "lightning_invoices"
down_revision = "a1a1a1a1a1a1"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"lightning_invoices",
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("bolt11", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("amount_sats", sa.Integer(), nullable=False),
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("payment_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("api_key_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("purpose", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("created_at", sa.Integer(), nullable=False),
sa.Column("expires_at", sa.Integer(), nullable=False),
sa.Column("paid_at", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("bolt11"),
sa.UniqueConstraint("payment_hash"),
)
def downgrade() -> None:
op.drop_table("lightning_invoices")
-103
View File
@@ -1,103 +0,0 @@
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
- Overview: overview.md
- Client Guide:
- Introduction: client/introduction.md
- Payment Flow: client/payments.md
- Integration: client/integration.md
- Provider Guide:
- Quick Start: provider/quickstart.md
- Dashboard: provider/dashboard.md
- Deployment: provider/deployment.md
- Configuration: provider/configuration.md
- Pricing: provider/pricing.md
- Advanced Pricing: provider/advanced-pricing.md
- Discovery: provider/discovery.md
- Tor Support: provider/tor.md
- API Reference:
- Overview: api/overview.md
- Authentication: api/authentication.md
- Endpoints: api/endpoints.md
- Errors: api/errors.md
- Contributing:
- Setup Development: contributing/setup.md
- Architecture: contributing/architecture.md
- Code Structure: contributing/code-structure.md
- Testing: contributing/testing.md
+2 -26
View File
@@ -1,26 +1,19 @@
[project]
name = "routstr"
version = "0.3.0"
version = "0.1.0"
description = "Payment proxy for your LLM endpoint using cashu and nostr."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi[standard]>=0.115",
"aiosqlite>=0.20",
"sqlmodel>=0.0.24",
"httpx[socks]>=0.25.2",
"greenlet>=3.2.1",
"alembic>=1.13",
"python-json-logger>=2.0.0",
"cashu",
"secp256k1",
"marshmallow>=3.13,<4.0",
"websockets>=12.0",
"nostr>=0.0.2",
"mdurl==0.1.2",
"pillow>=10",
"openai>=1.98.0",
]
[dependency-groups]
@@ -32,10 +25,6 @@ dev = [
"pytest-asyncio>=0.24.0",
"pytest-cov>=6.1.1",
"httpx>=0.25.2",
"psutil>=5.9.0",
"aiohttp>=3.9.0",
"pytest-benchmark>=4.0.0",
"routstr",
]
[tool.pytest.ini_options]
@@ -55,25 +44,13 @@ addopts = [
]
markers = [
"asyncio: marks tests as async (deselect with '-m \"not asyncio\"')",
"integration: marks tests as integration tests (deselect with '-m \"not integration\"')",
"integration: marks tests as integration tests",
"unit: marks tests as unit tests",
"slow: marks tests as slow running (deselect with '-m \"not slow\"')",
"requires_real_mint: marks tests that require a running Cashu mint instance",
"requires_docker: marks tests that require Docker services running (deselect with '-m \"not requires_docker\"')",
"performance: marks tests that measure performance metrics",
]
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["routstr"]
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = ["E501"]
exclude = ["examples"]
[tool.mypy]
python_version = "3.11"
@@ -85,5 +62,4 @@ disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.uv.sources]
routstr = { workspace = true }
secp256k1 = { git = "https://github.com/saschanaz/secp256k1-py", branch = "upgrade060" }
@@ -1,3 +1,7 @@
import dotenv
dotenv.load_dotenv()
from .core.main import app as fastapi_app # noqa
__all__ = ["fastapi_app"]
+551
View File
@@ -0,0 +1,551 @@
import hashlib
from typing import Optional
from fastapi import HTTPException
from sqlmodel import col, update
from .core import get_logger
from .core.db import ApiKey, AsyncSession
from .payment.cost_caculation import (
CostData,
CostDataError,
MaxCostData,
calculate_cost,
)
from .payment.helpers import get_max_cost_for_model
from .wallet import credit_balance
logger = get_logger(__name__)
# TODO: implement prepaid api key (not like it was before)
# PREPAID_API_KEY = os.environ.get("PREPAID_API_KEY", None)
# PREPAID_BALANCE = int(os.environ.get("PREPAID_BALANCE", "0")) * 1000 # Convert to msats
async def validate_bearer_key(
bearer_key: str,
session: AsyncSession,
refund_address: Optional[str] = None,
key_expiry_time: Optional[int] = None,
) -> ApiKey:
"""
Validates the provided API key using SQLModel.
If it's a cashu key, it redeems it and stores its hash and balance.
Otherwise checks if the hash of the key exists.
"""
logger.debug(
"Starting bearer key validation",
extra={
"key_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
"has_refund_address": bool(refund_address),
"has_expiry_time": bool(key_expiry_time),
},
)
if not bearer_key:
logger.error("Empty bearer key provided")
raise HTTPException(
status_code=401,
detail={
"error": {
"message": "API key or Cashu token required",
"type": "invalid_request_error",
"code": "missing_api_key",
}
},
)
if bearer_key.startswith("sk-"):
logger.debug(
"Processing sk- prefixed API key",
extra={"key_preview": bearer_key[:10] + "..."},
)
if existing_key := await session.get(ApiKey, bearer_key[3:]):
logger.info(
"Existing sk- API key found",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"balance": existing_key.balance,
"total_requests": existing_key.total_requests,
},
)
if key_expiry_time is not None:
existing_key.key_expiry_time = key_expiry_time
logger.debug(
"Updated key expiry time",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"expiry_time": key_expiry_time,
},
)
if refund_address is not None:
existing_key.refund_address = refund_address
logger.debug(
"Updated refund address",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"refund_address_preview": refund_address[:20] + "..."
if len(refund_address) > 20
else refund_address,
},
)
return existing_key
else:
logger.warning(
"sk- API key not found in database",
extra={"key_preview": bearer_key[:10] + "..."},
)
if bearer_key.startswith("cashu"):
logger.debug(
"Processing Cashu token",
extra={
"token_preview": bearer_key[:20] + "...",
"token_type": bearer_key[:6] if len(bearer_key) >= 6 else bearer_key,
},
)
try:
hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest()
logger.debug(
"Generated token hash", extra={"hash_preview": hashed_key[:16] + "..."}
)
if existing_key := await session.get(ApiKey, hashed_key):
logger.info(
"Existing Cashu token found",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"balance": existing_key.balance,
"total_requests": existing_key.total_requests,
},
)
if key_expiry_time is not None:
existing_key.key_expiry_time = key_expiry_time
logger.debug(
"Updated key expiry time for existing Cashu key",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"expiry_time": key_expiry_time,
},
)
if refund_address is not None:
existing_key.refund_address = refund_address
logger.debug(
"Updated refund address for existing Cashu key",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"refund_address_preview": refund_address[:20] + "..."
if len(refund_address) > 20
else refund_address,
},
)
return existing_key
logger.info(
"Creating new Cashu token entry",
extra={
"hash_preview": hashed_key[:16] + "...",
"has_refund_address": bool(refund_address),
"has_expiry_time": bool(key_expiry_time),
},
)
new_key = ApiKey(
hashed_key=hashed_key,
balance=0,
refund_address=refund_address,
key_expiry_time=key_expiry_time,
)
session.add(new_key)
await session.flush()
logger.debug(
"New key created, starting token redemption",
extra={"key_hash": hashed_key[:8] + "..."},
)
msats = await credit_balance(bearer_key, new_key, session)
if msats <= 0:
logger.error(
"Token redemption returned zero or negative amount",
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
)
raise Exception("Token redemption failed")
await session.refresh(new_key)
await session.commit()
logger.info(
"New Cashu token successfully redeemed and stored",
extra={
"key_hash": hashed_key[:8] + "...",
"redeemed_msats": msats,
"final_balance": new_key.balance,
},
)
return new_key
except Exception as e:
logger.error(
"Cashu token redemption failed",
extra={
"error": str(e),
"error_type": type(e).__name__,
"token_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
},
)
raise HTTPException(
status_code=401,
detail={
"error": {
"message": f"Invalid or expired Cashu key: {str(e)}",
"type": "invalid_request_error",
"code": "invalid_api_key",
}
},
)
logger.error(
"Invalid API key format",
extra={
"key_preview": bearer_key[:10] + "..."
if len(bearer_key) > 10
else bearer_key,
"key_length": len(bearer_key),
},
)
raise HTTPException(
status_code=401,
detail={
"error": {
"message": "Invalid API key",
"type": "invalid_request_error",
"code": "invalid_api_key",
}
},
)
async def pay_for_request(key: ApiKey, session: AsyncSession, body: dict) -> 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",
extra={
"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:
logger.warning(
"Insufficient balance for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"balance": key.balance,
"required": cost_per_request,
"shortfall": cost_per_request - key.balance,
"model": model,
},
)
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.balance} available.",
"type": "insufficient_quota",
"code": "insufficient_balance",
}
},
)
logger.debug(
"Charging base cost for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"cost": cost_per_request,
"balance_before": key.balance,
},
)
# Charge the base cost for the request atomically to avoid race conditions
stmt = (
update(ApiKey)
.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,
total_requests=col(ApiKey.total_requests) + 1,
)
)
result = await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Concurrent request depleted balance",
extra={
"key_hash": key.hashed_key[:8] + "...",
"required_cost": cost_per_request,
"current_balance": key.balance,
},
)
# Another concurrent request spent the balance first
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.balance} available.",
"type": "insufficient_quota",
"code": "insufficient_balance",
}
},
)
await session.refresh(key)
logger.info(
"Payment processed successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"charged_amount": cost_per_request,
"new_balance": key.balance,
"total_spent": key.total_spent,
"total_requests": key.total_requests,
"model": model,
},
)
return cost_per_request
async def revert_pay_for_request(
key: ApiKey, session: AsyncSession, cost_per_request: int
) -> None:
stmt = (
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,
total_requests=col(ApiKey.total_requests) - 1,
)
)
result = await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"failed to revert request payment: {cost_per_request} mSats required. {key.balance} available.",
"type": "payment_error",
"code": "payment_error",
}
},
)
await session.refresh(key)
async def adjust_payment_for_tokens(
key: ApiKey, response_data: dict, session: AsyncSession, deducted_max_cost: int
) -> dict:
"""
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")
logger.debug(
"Starting payment adjustment for tokens",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": model,
"deducted_max_cost": deducted_max_cost,
"current_balance": key.balance,
"has_usage": "usage" in response_data,
},
)
match calculate_cost(response_data, deducted_max_cost):
case MaxCostData() as cost:
logger.debug(
"Using max cost data (no token adjustment)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": model,
"max_cost": cost.total_msats,
},
)
return cost.dict()
case CostData() as cost:
# 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
logger.info(
"Calculated token-based cost",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": model,
"token_cost": cost.total_msats,
"deducted_max_cost": deducted_max_cost,
"cost_difference": cost_difference,
"input_msats": cost.input_msats,
"output_msats": cost.output_msats,
},
)
if cost_difference == 0:
logger.debug(
"No cost adjustment needed",
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
)
await session.commit()
return cost.dict()
if cost_difference > 0:
# Need to charge more
logger.info(
"Additional charge required for token usage",
extra={
"key_hash": key.hashed_key[:8] + "...",
"additional_charge": cost_difference,
"current_balance": key.balance,
"sufficient_balance": key.balance >= cost_difference,
"model": model,
},
)
if key.balance < cost_difference:
logger.warning(
"Insufficient balance for token-based pricing adjustment",
extra={
"key_hash": key.hashed_key[:8] + "...",
"required": cost_difference,
"available": key.balance,
"shortfall": cost_difference - key.balance,
"model": model,
},
)
await session.commit()
else:
charge_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.balance) >= cost_difference)
.values(
balance=col(ApiKey.balance) - cost_difference,
total_spent=col(ApiKey.total_spent) + cost_difference,
)
)
result = await session.exec(charge_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount:
cost.total_msats = deducted_max_cost + cost_difference
await session.refresh(key)
logger.info(
"Additional charge applied successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"charged_amount": cost_difference,
"new_balance": key.balance,
"total_cost": cost.total_msats,
"model": model,
},
)
else:
logger.warning(
"Failed to apply additional charge (concurrent operation)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"attempted_charge": cost_difference,
"model": model,
},
)
else:
# Refund some of the base cost
refund = abs(cost_difference)
logger.info(
"Refunding excess payment",
extra={
"key_hash": key.hashed_key[:8] + "...",
"refund_amount": refund,
"current_balance": key.balance,
"model": model,
},
)
refund_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
balance=col(ApiKey.balance) + refund,
total_spent=col(ApiKey.total_spent) - refund,
)
)
await session.exec(refund_stmt) # type: ignore[call-overload]
await session.commit()
cost.total_msats = deducted_max_cost - refund
await session.refresh(key)
logger.info(
"Refund processed successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"refunded_amount": refund,
"new_balance": key.balance,
"final_cost": cost.total_msats,
"model": model,
},
)
return cost.dict()
case CostDataError() as error:
logger.error(
"Cost calculation error during payment adjustment",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": model,
"error_message": error.message,
"error_code": error.code,
},
)
raise HTTPException(
status_code=400,
detail={
"error": {
"message": error.message,
"type": "invalid_request_error",
"code": error.code,
}
},
)
+100
View File
@@ -0,0 +1,100 @@
from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, Header, HTTPException
from .auth import validate_bearer_key
from .core.db import ApiKey, AsyncSession, get_session
from .wallet import credit_balance, send_to_lnurl, send_token
router = APIRouter()
balance_router = APIRouter(prefix="/v1/balance")
async def get_key_from_header(
authorization: Annotated[str, Header(...)],
session: AsyncSession = Depends(get_session),
) -> ApiKey:
if authorization.startswith("Bearer "):
return await validate_bearer_key(authorization[7:], session)
raise HTTPException(
status_code=401,
detail="Invalid authorization. Use 'Bearer <cashu-token>' or 'Bearer <api-key>'",
)
# TODO: remove this endpoint when frontend is updated
@router.get("/", include_in_schema=False)
async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
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 {
"api_key": "sk-" + key.hashed_key,
"balance": key.balance,
}
@router.post("/topup")
async def topup_wallet_endpoint(
cashu_token: str,
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict[str, int]:
amount_msats = await credit_balance(cashu_token, key, session)
return {"msats": amount_msats}
@router.post("/refund")
async def refund_wallet_endpoint(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict:
remaining_balance_msats = key.balance
if remaining_balance_msats == 0:
raise HTTPException(status_code=400, detail="No balance to refund")
# Perform refund operation first, before modifying balance
if key.refund_address:
await send_to_lnurl(remaining_balance_msats, "msat", key.refund_address)
result = {"recipient": key.refund_address, "msat": remaining_balance_msats}
else:
# Convert msats to sats for cashu wallet
remaining_balance_sats = remaining_balance_msats // 1000
if remaining_balance_sats == 0:
raise HTTPException(
status_code=400, detail="Balance too small to refund (less than 1 sat)"
)
# TODO: choose currency and mint based on what user has configured
token = await send_token(remaining_balance_sats, "sat")
result = {"msats": remaining_balance_msats, "recipient": None, "token": token}
await session.delete(key)
await session.commit()
return result
@router.api_route(
"/{path:path}",
methods=["GET", "POST", "PUT", "DELETE"],
include_in_schema=False,
response_model=None,
)
async def wallet_catch_all(path: str) -> NoReturn:
raise HTTPException(
status_code=404, detail="Not found check /docs for available endpoints"
)
balance_router.include_router(router)
deprecated_wallet_router = APIRouter(prefix="/v1/wallet", include_in_schema=False)
deprecated_wallet_router.include_router(router)
+405
View File
@@ -0,0 +1,405 @@
import os
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from sqlmodel import select
from ..wallet import get_balance, send_token
from .db import ApiKey, create_session
admin_router = APIRouter(prefix="/admin", include_in_schema=False)
class WithdrawRequest(BaseModel):
amount: int
def login_form() -> str:
return """<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
form {
display: flex;
flex-direction: column;
gap: 10px;
}
input[type="password"] {
padding: 8px;
}
button {
padding: 8px;
cursor: pointer;
}
</style>
<script>
function handleSubmit(e) {
e.preventDefault();
const password = document.getElementById('password').value;
document.cookie = `admin_password=${password}; path=/; max-age=86400`;
window.location.reload();
}
</script>
</head>
<body>
<form onsubmit="handleSubmit(event)">
<input type="password" id="password" placeholder="Admin Password" required>
<button type="submit">Login</button>
</form>
</body>
</html>
"""
def info(content: str) -> str:
return f"""<!DOCTYPE html>
<html>
<head>
<style>
body {{
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}}
</style>
</head>
<body>
<div style="text-align: center;">
{content}
</div>
</body>
</html>
"""
def admin_auth() -> str:
if os.getenv("ADMIN_PASSWORD", "") == "":
return info("Please set a secure ADMIN_PASSWORD= in your ENV variables.")
else:
return login_form()
async def dashboard(request: Request) -> str:
# fetch cashu / api-key data from database
async with create_session() as session:
result = await session.exec(select(ApiKey))
api_keys = result.all()
api_keys_table_rows = []
for key in api_keys:
expiry_time_utc = (
datetime.fromtimestamp(key.key_expiry_time, tz=timezone.utc)
if key.key_expiry_time is not None
else None
)
expiry_time_human_readable = (
expiry_time_utc.strftime("%Y-%m-%d %H:%M:%S") if expiry_time_utc else ""
)
api_keys_table_rows.append(
f"<tr><td>{key.hashed_key}</td><td>{key.balance}</td><td>{key.total_spent}</td><td>{key.total_requests}</td><td>{key.refund_address}</td><td>{'{} ({} UTC)'.format(key.key_expiry_time, expiry_time_human_readable) if key.key_expiry_time else key.key_expiry_time}</td></tr>"
)
# Calculate the total balance of all API keys using integer arithmetic to
# avoid rounding issues.
total_user_balance = sum(key.balance for key in api_keys) // 1000
# Fetch balance from cashu
current_balance = await get_balance("sat")
owner_balance = current_balance - total_user_balance
return f"""<!DOCTYPE html>
<html>
<head>
<style>
table {{
width: 100%;
border-collapse: collapse;
}}
th, td {{
border: 1px solid black;
padding: 8px;
text-align: left;
}}
button {{
padding: 8px 16px;
cursor: pointer;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
margin-right: 10px;
}}
button:hover {{
background-color: #0056b3;
}}
button:disabled {{
background-color: #6c757d;
cursor: not-allowed;
}}
#token-result {{
margin-top: 20px;
padding: 15px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
word-break: break-all;
display: none;
max-width: 100%;
}}
#token-text {{
font-family: monospace;
font-size: 12px;
background-color: #e9ecef;
padding: 10px;
border-radius: 4px;
margin: 10px 0;
}}
.copy-btn {{
background-color: #28a745;
padding: 4px 8px;
font-size: 12px;
}}
.copy-btn:hover {{
background-color: #1e7e34;
}}
.refresh-btn {{
background-color: #ffc107;
color: black;
}}
.refresh-btn:hover {{
background-color: #e0a800;
}}
.modal {{
display: none;
position: fixed;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.4);
}}
.modal-content {{
background-color: #fefefe;
margin: 15% auto;
padding: 20px;
border: 1px solid #888;
width: 300px;
border-radius: 8px;
text-align: center;
}}
.close {{
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}}
.close:hover {{
color: black;
}}
input[type="number"] {{
width: 100%;
padding: 8px;
margin: 10px 0;
border: 1px solid #ddd;
border-radius: 4px;
}}
.warning {{
color: #dc3545;
font-weight: bold;
margin: 10px 0;
}}
</style>
<script>
function openWithdrawModal() {{
const modal = document.getElementById('withdraw-modal');
const amountInput = document.getElementById('withdraw-amount');
amountInput.value = {owner_balance};
modal.style.display = 'block';
}}
function closeWithdrawModal() {{
const modal = document.getElementById('withdraw-modal');
modal.style.display = 'none';
}}
function checkAmount() {{
const amount = parseInt(document.getElementById('withdraw-amount').value);
const warning = document.getElementById('withdraw-warning');
const ownerBalance = {owner_balance};
if (amount > ownerBalance && amount <= {current_balance}) {{
warning.style.display = 'block';
}} else {{
warning.style.display = 'none';
}}
}}
async function performWithdraw() {{
const amount = parseInt(document.getElementById('withdraw-amount').value);
const button = document.getElementById('confirm-withdraw-btn');
const tokenResult = document.getElementById('token-result');
if (!amount || amount <= 0) {{
alert('Please enter a valid amount');
return;
}}
if (amount > {current_balance}) {{
alert('Amount exceeds wallet balance');
return;
}}
button.disabled = true;
button.textContent = 'Withdrawing...';
try {{
const response = await fetch('/admin/withdraw', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json',
}},
credentials: 'same-origin',
body: JSON.stringify({{ amount: amount }})
}});
if (response.ok) {{
const data = await response.json();
document.getElementById('token-text').textContent = data.token;
tokenResult.style.display = 'block';
closeWithdrawModal();
}} else {{
const errorData = await response.json();
alert('Failed to withdraw balance: ' + (errorData.detail || 'Unknown error'));
}}
}} catch (error) {{
alert('Error: ' + error.message);
}} finally {{
button.disabled = false;
button.textContent = 'Withdraw';
}}
}}
function copyToken() {{
const tokenText = document.getElementById('token-text');
navigator.clipboard.writeText(tokenText.textContent).then(() => {{
const copyBtn = document.getElementById('copy-btn');
const originalText = copyBtn.textContent;
copyBtn.textContent = 'Copied!';
setTimeout(() => {{
copyBtn.textContent = originalText;
}}, 2000);
}}).catch(err => {{
alert('Failed to copy token');
}});
}}
function refreshPage() {{
window.location.reload();
}}
window.onclick = function(event) {{
const modal = document.getElementById('withdraw-modal');
if (event.target == modal) {{
closeWithdrawModal();
}}
}}
</script>
</head>
<body>
<h1>Admin Dashboard</h1>
<h2>Current Cashu Balance</h2>
<p>Your Balance: {owner_balance} sats</p>
<p>The balance is calculated by subtracting the combined user balance from the total Cashu wallet balance.</p>
<p>Total Cashu Balance: {current_balance} sats</p>
<p>User Balance: {total_user_balance} sats</p>
<button id="withdraw-btn" onclick="openWithdrawModal()" {"disabled" if current_balance <= 0 else ""}>
Withdraw Balance
</button>
<button class="refresh-btn" onclick="refreshPage()">
Refresh Dashboard
</button>
<div id="withdraw-modal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeWithdrawModal()">&times;</span>
<h3>Withdraw Balance</h3>
<p>Enter amount to withdraw (sats):</p>
<input type="number" id="withdraw-amount" min="1" max="{current_balance}" placeholder="Amount in sats" oninput="checkAmount()">
<p>Maximum: {current_balance} sats</p>
<p>Your recommended balance: {owner_balance} sats</p>
<div id="withdraw-warning" class="warning" style="display: none;">
⚠️ Warning: Withdrawing more than your balance will use user funds!
</div>
<button id="confirm-withdraw-btn" onclick="performWithdraw()">Withdraw</button>
<button onclick="closeWithdrawModal()" style="background-color: #6c757d;">Cancel</button>
</div>
</div>
<div id="token-result">
<strong>Withdrawal Token:</strong>
<div id="token-text"></div>
<button id="copy-btn" class="copy-btn" onclick="copyToken()">Copy Token</button>
<p><em>Save this token! It represents your withdrawn balance.</em></p>
</div>
<h2>User's API Keys</h2>
<table>
<tr>
<th>Hashed Key</th>
<th>Balance (mSats)</th>
<th>Total Spent (mSats)</th>
<th>Total Requests</th>
<th>Refund Address</th>
<th>Refund Time</th>
</tr>
{"".join(api_keys_table_rows)}
</table>
</body>
</html>
"""
@admin_router.get("/", response_class=HTMLResponse)
async def admin(request: Request) -> str:
admin_cookie = request.cookies.get("admin_password")
if admin_cookie and admin_cookie == os.getenv("ADMIN_PASSWORD"):
return await dashboard(request)
return admin_auth()
@admin_router.post("/withdraw")
async def withdraw(
request: Request, withdraw_request: WithdrawRequest
) -> dict[str, str]:
admin_cookie = request.cookies.get("admin_password")
if not admin_cookie or admin_cookie != os.getenv("ADMIN_PASSWORD"):
raise HTTPException(status_code=403, detail="Unauthorized")
current_balance = await get_balance("sat")
if withdraw_request.amount <= 0:
raise HTTPException(
status_code=400, detail="Withdrawal amount must be positive"
)
if withdraw_request.amount > current_balance:
raise HTTPException(status_code=400, detail="Insufficient wallet balance")
token = await send_token(withdraw_request.amount, "sat")
return {"token": token}
+48
View File
@@ -0,0 +1,48 @@
import os
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio.engine import create_async_engine
from sqlmodel import Field, SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///keys.db")
engine = create_async_engine(DATABASE_URL, echo=False) # echo=True for debugging SQL
class ApiKey(SQLModel, table=True): # type: ignore
__tablename__ = "api_keys"
hashed_key: str = Field(primary_key=True)
balance: int = Field(default=0, description="Balance in millisatoshis (msats)")
refund_address: str | None = Field(
default=None,
description="Lightning address to refund remaining balance after key expires",
)
key_expiry_time: int | None = Field(
default=None,
description="Unix-timestamp after which the cashu-token's balance gets refunded to the refund_address",
)
total_spent: int = Field(
default=0, description="Total spent in millisatoshis (msats)"
)
total_requests: int = Field(default=0)
async def init_db() -> None:
"""Initializes the database and creates tables if they don't exist."""
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSession(engine, expire_on_commit=False) as session:
yield session
@asynccontextmanager
async def create_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSession(engine, expire_on_commit=False) as session:
yield session
@@ -1,40 +1,3 @@
"""
Logging configuration for Routstr.
CRITICAL LOG MESSAGES FOR USAGE STATISTICS:
===========================================
The following log messages are parsed by the usage tracking system (routstr/core/admin.py).
DO NOT modify or remove these messages without updating the usage tracking logic:
1. "Received proxy request" (INFO) - routstr/proxy.py
- Used to count total incoming requests
- Includes model information in context
2. "Payment adjustment completed for streaming" (INFO) - routstr/upstream/base.py
"Payment adjustment completed for non-streaming" (INFO) - routstr/upstream/base.py
- Used to track successful completions and revenue
- The 'cost_data.total_msats' field is extracted for revenue calculation
- Must include 'cost_data' in extra dict
3. "Payment processed successfully" (INFO) - routstr/auth.py
- Used to count successful payment processing events
- Tracks payment-related metrics
4. "Upstream request failed, revert payment" (WARNING) - routstr/proxy.py
- Used to track failed requests and refunds
- The 'max_cost_for_model' field is extracted for refund calculation
- Must include 'max_cost_for_model' in extra dict
5. Any ERROR level logs with "upstream" in the message
- Used to count upstream provider errors
- Helps identify service reliability issues
If you need to modify these messages, ensure you also update the parsing logic in:
- routstr/core/admin.py:_aggregate_metrics_by_time()
- routstr/core/admin.py:_get_summary_stats()
- routstr/core/admin.py:get_revenue_by_model()
"""
import logging.config
import logging.handlers
import os
@@ -126,7 +89,7 @@ def get_package_version() -> str:
return version
current_path = current_path.parent
# Fallback: try the simple path resolution (3 levels up for routstr/logging/logging_config.py)
# Fallback: try the simple path resolution (3 levels up for router/logging/logging_config.py)
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
if pyproject_path.exists():
with open(pyproject_path, "rb") as f:
@@ -152,23 +115,6 @@ class VersionFilter(logging.Filter):
return True
class RequestIdFilter(logging.Filter):
"""Filter to add request ID to all log records."""
def filter(self, record: logging.LogRecord) -> bool:
"""Add request ID to the log record if available."""
try:
# Import here to avoid circular imports
from .middleware import request_id_context
request_id = request_id_context.get(None)
record.request_id = request_id if request_id else "no-request-id"
except ImportError:
# If middleware isn't available yet, just use default
record.request_id = "no-request-id"
return True
class SecurityFilter(logging.Filter):
"""Filter to remove sensitive information from logs."""
@@ -192,24 +138,21 @@ class SecurityFilter(logging.Filter):
"""Filter out sensitive information from log records."""
try:
message = record.getMessage()
standalone_patterns = [
r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives)
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
r"nsec[a-z0-9]+", # Nostr Public / Private Key
]
for pattern in standalone_patterns:
message = re.sub(pattern, "[REDACTED]", message, flags=re.IGNORECASE)
for key in self.SENSITIVE_KEYS:
if key in message.lower():
key_patterns = [
rf"{key}\s*[:=]\s*([a-zA-Z0-9_\-\.=/+]+)", # key:value or key=value (including any variant with spaces)
rf'{key}\s*[:=]\s*["\']([^"\']+)["\']', # key:"value" or key='value' (including any variant with spaces)
patterns = [
rf"{key}[:\s=]+([a-zA-Z0-9_\-\.]+)", # key: value or key=value
rf'{key}[:\s=]+["\']([^"\']+)["\']', # key: "value" or key='value'
r"Bearer\s+([a-zA-Z0-9_\-\.]+)", # Bearer token
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
]
for pattern in key_patterns:
for pattern in patterns:
message = re.sub(
pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE
)
record.msg = message
record.args = ()
@@ -221,12 +164,7 @@ class SecurityFilter(logging.Filter):
def get_log_level() -> str:
"""Get log level from environment variable."""
try:
from .settings import settings
level = settings.log_level.upper()
except Exception:
level = os.environ.get("LOG_LEVEL", "INFO").upper()
level = os.environ.get("LOG_LEVEL", "INFO").upper()
# Validate log level - if invalid, default to INFO
valid_levels = {"TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
if level not in valid_levels:
@@ -236,16 +174,11 @@ def get_log_level() -> str:
def should_enable_console_logging() -> bool:
"""Check if console logging should be enabled."""
try:
from .settings import settings
return bool(settings.enable_console_logging)
except Exception:
return os.environ.get("ENABLE_CONSOLE_LOGGING", "true").lower() in (
"true",
"1",
"yes",
)
return os.environ.get("ENABLE_CONSOLE_LOGGING", "true").lower() in (
"true",
"1",
"yes",
)
def setup_logging() -> None:
@@ -265,13 +198,12 @@ def setup_logging() -> None:
"formatters": {
"json": {
"()": jsonlogger.JsonFormatter,
"format": "%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s %(lineno)d %(version)s %(request_id)s",
"format": "%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s %(lineno)d %(version)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"filters": {
"version_filter": {"()": VersionFilter},
"request_id_filter": {"()": RequestIdFilter},
"security_filter": {"()": SecurityFilter},
},
"handlers": {
@@ -282,7 +214,7 @@ def setup_logging() -> None:
"show_path": False,
"rich_tracebacks": True,
"markup": True,
"filters": ["request_id_filter", "security_filter"],
"filters": ["security_filter"],
},
"file": {
"()": DailyRotatingFileHandler,
@@ -293,83 +225,57 @@ def setup_logging() -> None:
"interval": 1, # Every 1 day
"backupCount": 30, # Keep 30 days of logs
"atTime": None, # Rotate at midnight (00:00)
"filters": ["version_filter", "request_id_filter", "security_filter"],
"filters": ["version_filter", "security_filter"],
},
},
"loggers": {
"routstr": {
"router": {
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"routstr.payment": {
"router.payment": {
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"routstr.proxy": {
"router.cashu": {
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"routstr.auth": {
"router.proxy": {
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"routstr.payment.models": {
"router.auth": {
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"routstr.core.exceptions": {
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"routstr.core.middleware": {
"level": log_level,
"handlers": ["file"],
"propagate": False,
},
# Suppress verbose third-party logging
"httpx": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"openai": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"httpcore": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"websockets": {
"level": "WARNING",
"handlers": [],
"propagate": False,
},
"uvicorn.access": {
"level": "WARNING",
"handlers": ["file"],
"propagate": False,
},
"uvicorn.error": {
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},
"aiosqlite": {"level": "ERROR", "handlers": [], "propagate": False},
"alembic": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"uvicorn.error": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},
},
"root": {
"level": log_level,
+102
View File
@@ -0,0 +1,102 @@
import asyncio
import os
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from ..balance import balance_router, deprecated_wallet_router
from ..discovery import providers_router
from ..payment.models import MODELS, models_router, update_sats_pricing
from ..proxy import proxy_router
from ..wallet import check_for_refunds, periodic_payout
from .admin import admin_router
from .db import init_db
from .logging import get_logger, setup_logging
# Initialize logging first
setup_logging()
logger = get_logger(__name__)
__version__ = "0.0.1"
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
logger.info("Application startup initiated", extra={"version": __version__})
try:
await init_db()
pricing_task = asyncio.create_task(update_sats_pricing())
refund_task = asyncio.create_task(check_for_refunds())
payout_task = asyncio.create_task(periodic_payout())
yield
except Exception as e:
logger.error(
"Application startup failed",
extra={"error": str(e), "error_type": type(e).__name__},
)
raise
finally:
logger.info("Application shutdown initiated")
refund_task.cancel()
pricing_task.cancel()
payout_task.cancel()
try:
await asyncio.gather(
pricing_task, refund_task, payout_task, return_exceptions=True
)
logger.info("Background tasks stopped successfully")
except Exception as e:
logger.error(
"Error stopping background tasks",
extra={"error": str(e), "error_type": type(e).__name__},
)
app = FastAPI(
version=__version__,
title=os.environ.get("NAME", "ARoutstrNode" + __version__),
description=os.environ.get("DESCRIPTION", "A Routstr Node"),
contact={"name": os.environ.get("NAME", ""), "npub": os.environ.get("NPUB", "")},
lifespan=lifespan,
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=os.environ.get("CORS_ORIGINS", "*").split(","),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/", include_in_schema=False)
@app.get("/v1/info")
async def info() -> dict:
logger.info("Info endpoint accessed")
return {
"name": app.title,
"description": app.description,
"version": __version__,
"npub": os.environ.get("NPUB", ""),
"mints": os.environ.get("CASHU_MINTS", "").split(","),
"http_url": os.environ.get("HTTP_URL", ""),
"onion_url": os.environ.get("ONION_URL", ""),
"models": MODELS,
}
app.include_router(models_router)
app.include_router(admin_router)
app.include_router(balance_router)
app.include_router(deprecated_wallet_router)
app.include_router(providers_router)
app.include_router(proxy_router)
+268
View File
@@ -0,0 +1,268 @@
import asyncio
import json
import os
import random
import string
from typing import Any
import httpx
import websockets
from fastapi import APIRouter
providers_router = APIRouter(prefix="/v1/providers")
def generate_subscription_id() -> str:
"""Generate a random subscription ID."""
return "".join(random.choices(string.ascii_lowercase + string.digits, k=10))
async def query_nostr_relay_for_providers(
relay_url: str,
pubkey: str | None = None,
limit: int = 1000,
timeout: int = 30,
) -> list[dict[str, Any]]:
"""
Query a Nostr relay for provider announcements using RIP-02 spec.
Searches for kind 31338 events (Routstr Provider Announcements).
"""
events = []
# Build filter according to RIP-02 spec
filter_obj: dict[str, Any] = {
"kinds": [31338], # RIP-02 Provider Announcement events
"limit": limit,
}
# If specific pubkey provided, filter by author
if pubkey:
filter_obj["authors"] = [pubkey]
sub_id = generate_subscription_id()
req_message = json.dumps(["REQ", sub_id, filter_obj])
try:
async with websockets.connect(relay_url, timeout=timeout) as websocket:
print("Connected to relay, searching for kind 31338 events")
await websocket.send(req_message)
while True:
try:
message = await asyncio.wait_for(websocket.recv(), timeout=5)
data = json.loads(message)
if data[0] == "EVENT" and data[1] == sub_id:
event = data[2]
print(f"Found provider announcement: {event['id']}")
events.append(event)
elif data[0] == "EOSE" and data[1] == sub_id:
print("Received EOSE message")
break
elif data[0] == "NOTICE":
print(f"Relay notice: {data[1]}")
except asyncio.TimeoutError:
print("Timeout waiting for message")
break
except json.JSONDecodeError:
print("Failed to decode message as JSON")
continue
await websocket.send(json.dumps(["CLOSE", sub_id]))
except Exception as e:
print(f"Query failed: {e}")
print(f"Query complete. Found {len(events)} provider announcements")
return events
def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
"""
Parse a kind 31338 provider announcement event according to RIP-02 spec.
Returns structured provider data or None if invalid.
"""
try:
# Extract required tags according to RIP-02
tags = event.get("tags", [])
# Find required tags
endpoint_url = None
provider_name = None
d_tag = 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:
print(
f"Invalid provider announcement - missing required tags: {event['id']}"
)
return None
# Extract optional tags
description = None
contact = None
pricing_url = None
supported_models = []
for tag in tags:
if len(tag) >= 2:
if tag[0] == "description":
description = tag[1]
elif tag[0] == "contact":
contact = tag[1]
elif tag[0] == "pricing":
pricing_url = tag[1]
elif tag[0] == "model":
supported_models.append(tag[1])
return {
"id": event["id"],
"pubkey": event["pubkey"],
"created_at": event["created_at"],
"d_tag": d_tag,
"endpoint_url": endpoint_url,
"name": provider_name,
"description": description,
"contact": contact,
"pricing_url": pricing_url,
"supported_models": supported_models,
"content": event.get("content", ""),
}
except Exception as e:
print(f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}")
return None
async def get_cache() -> list[dict[str, Any]]:
return [] # TODO: Implement cache
async def fetch_provider_health(endpoint_url: str) -> dict[str, Any]:
"""Check if a provider endpoint is healthy by making a GET request."""
try:
# Determine if we need Tor proxy based on .onion domain
is_onion = ".onion" in endpoint_url
# Set up client arguments conditionally
proxies = None
if is_onion:
# Get Tor proxy URL from environment variable
tor_proxy = os.getenv("TOR_PROXY_URL", "socks5://127.0.0.1:9050")
proxies = {"http://": tor_proxy, "https://": tor_proxy} # type: ignore[assignment]
async with httpx.AsyncClient(
timeout=httpx.Timeout(30.0),
follow_redirects=True,
proxies=proxies, # type: ignore[arg-type]
) as client:
# Try to fetch models endpoint first (common for AI providers)
models_url = f"{endpoint_url.rstrip('/')}/v1/models"
try:
response = await client.get(models_url)
if response.status_code == 200:
return {
"status_code": response.status_code,
"endpoint": "models",
"json": response.json(),
}
except Exception:
pass
# Fallback to root endpoint
response = await client.get(endpoint_url)
return {
"status_code": response.status_code,
"endpoint": "root",
"json": response.json()
if response.headers.get("content-type", "").startswith(
"application/json"
)
else {"message": "OK"},
}
except Exception as e:
return {
"status_code": 500,
"endpoint": "error",
"json": {"error": f"Failed to fetch provider: {str(e)}"},
}
@providers_router.get("/")
async def get_providers(
include_json: bool = False, pubkey: str | None = None
) -> dict[str, list[dict[str, Any]]]:
"""
Discover Routstr providers using RIP-02 specification.
Searches for kind 31338 provider announcement events on Nostr relays.
Reference: https://github.com/Routstr/protocol/blob/main/RIP-02.md
"""
# Default relays for provider discovery
discovery_relays = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://relay.routstr.com",
]
all_events = []
event_ids = set() # To avoid duplicates
# Query multiple relays for provider announcements
for relay_url in discovery_relays:
print(f"\nQuerying relay for providers: {relay_url}")
try:
events = await query_nostr_relay_for_providers(
relay_url=relay_url,
pubkey=pubkey,
limit=100,
)
# Add unique events
for event in events:
if event["id"] not in event_ids:
event_ids.add(event["id"])
all_events.append(event)
print(f"Got {len(events)} provider announcements from {relay_url}")
except Exception as e:
print(f"Failed to query {relay_url}: {e}")
continue
print(f"Found {len(all_events)} total unique provider announcements")
# Parse provider announcements according to RIP-02
providers = []
for event in all_events:
parsed_provider = parse_provider_announcement(event)
if parsed_provider:
providers.append(parsed_provider)
print(f"Parsed {len(providers)} valid provider announcements")
# Check provider health if requested
healthy_providers: list[dict[str, Any]] = []
for provider in providers:
endpoint_url = provider["endpoint_url"]
if include_json:
health_check = await fetch_provider_health(endpoint_url)
provider_data = {"provider": provider, "health": health_check}
healthy_providers.append(provider_data)
else:
# Just return the provider info without health check
healthy_providers.append(provider)
return {"providers": healthy_providers}
@@ -1,4 +1,4 @@
from .cost_calculation import CostData, CostDataError, MaxCostData, calculate_cost
from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost
__all__ = [
"CostData",
+170
View File
@@ -0,0 +1,170 @@
import math
import os
from pydantic import BaseModel
from ..core import get_logger
from .models import MODELS
logger = get_logger(__name__)
COST_PER_REQUEST = (
int(os.environ.get("COST_PER_REQUEST", "1")) * 1000
) # Convert to msats
COST_PER_1K_INPUT_TOKENS = (
int(os.environ.get("COST_PER_1K_INPUT_TOKENS", "0")) * 1000
) # Convert to msats
COST_PER_1K_OUTPUT_TOKENS = (
int(os.environ.get("COST_PER_1K_OUTPUT_TOKENS", "0")) * 1000
) # Convert to msats
MODEL_BASED_PRICING = os.environ.get("MODEL_BASED_PRICING", "false").lower() == "true"
logger.info(
"Cost calculation initialized",
extra={
"cost_per_request_msats": COST_PER_REQUEST,
"cost_per_1k_input_tokens_msats": COST_PER_1K_INPUT_TOKENS,
"cost_per_1k_output_tokens_msats": COST_PER_1K_OUTPUT_TOKENS,
"model_based_pricing": MODEL_BASED_PRICING,
},
)
class CostData(BaseModel):
base_msats: int
input_msats: int
output_msats: int
total_msats: int
class MaxCostData(CostData):
pass
class CostDataError(BaseModel):
message: str
code: str
def calculate_cost(
response_data: dict, max_cost: int
) -> CostData | MaxCostData | CostDataError:
"""
Calculate the cost of an API request based on token usage.
Args:
response_data: Response data containing usage information
max_cost: Maximum cost in millisats
Returns:
Cost data or error information
"""
logger.debug(
"Starting cost calculation",
extra={
"max_cost_msats": max_cost,
"has_usage_data": "usage" in response_data,
"response_model": response_data.get("model", "unknown"),
},
)
cost_data = MaxCostData(
base_msats=max_cost,
input_msats=0,
output_msats=0,
total_msats=max_cost,
)
if "usage" not in response_data or response_data["usage"] is None:
logger.warning(
"No usage data in response, using base cost only",
extra={
"max_cost_msats": max_cost,
"model": response_data.get("model", "unknown"),
},
)
return cost_data
MSATS_PER_1K_INPUT_TOKENS = COST_PER_1K_INPUT_TOKENS
MSATS_PER_1K_OUTPUT_TOKENS = COST_PER_1K_OUTPUT_TOKENS
if MODEL_BASED_PRICING and MODELS:
response_model = response_data.get("model", "")
logger.debug(
"Using model-based pricing",
extra={
"model": response_model,
"available_models": [model.id for model in MODELS],
},
)
if response_model not in [model.id for model in MODELS]:
logger.error(
"Invalid model in response",
extra={
"response_model": response_model,
"available_models": [model.id for model in MODELS],
},
)
return CostDataError(
message=f"Invalid model in response: {response_model}",
code="model_not_found",
)
model = next(model for model in MODELS if model.id == response_model)
if model.sats_pricing is None:
logger.error(
"Model pricing not defined",
extra={"model": response_model, "model_id": model.id},
)
return CostDataError(
message="Model pricing not defined", code="pricing_not_found"
)
MSATS_PER_1K_INPUT_TOKENS = model.sats_pricing.prompt * 1_000_000 # type: ignore
MSATS_PER_1K_OUTPUT_TOKENS = model.sats_pricing.completion * 1_000_000 # type: ignore
logger.info(
"Applied model-specific pricing",
extra={
"model": response_model,
"input_price_msats_per_1k": MSATS_PER_1K_INPUT_TOKENS,
"output_price_msats_per_1k": MSATS_PER_1K_OUTPUT_TOKENS,
},
)
if not (MSATS_PER_1K_OUTPUT_TOKENS and MSATS_PER_1K_INPUT_TOKENS):
logger.warning(
"No token pricing configured, using base cost",
extra={
"base_cost_msats": max_cost,
"model": response_data.get("model", "unknown"),
},
)
return cost_data
input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0)
output_tokens = response_data.get("usage", {}).get("completion_tokens", 0)
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
token_based_cost = math.ceil(input_msats + output_msats)
logger.info(
"Calculated token-based cost",
extra={
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"input_cost_msats": input_msats,
"output_cost_msats": output_msats,
"total_cost_msats": token_based_cost,
"model": response_data.get("model", "unknown"),
},
)
return CostData(
base_msats=0,
input_msats=int(input_msats),
output_msats=int(output_msats),
total_msats=token_based_cost,
)
+232
View File
@@ -0,0 +1,232 @@
import json
import os
from typing import Mapping, Optional
from fastapi import HTTPException, Response
from ..core import get_logger
from ..wallet import deserialize_token_from_string
from .cost_caculation import COST_PER_REQUEST, MODEL_BASED_PRICING
from .models import MODELS
logger = get_logger(__name__)
UPSTREAM_BASE_URL = os.environ["UPSTREAM_BASE_URL"]
UPSTREAM_API_KEY = os.environ.get("UPSTREAM_API_KEY", "")
logger.info(
"Payment helpers initialized",
extra={
"upstream_base_url": UPSTREAM_BASE_URL,
"has_upstream_api_key": bool(UPSTREAM_API_KEY),
"model_based_pricing": MODEL_BASED_PRICING,
},
)
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
logger.debug(
"Using X-Cashu token",
extra={
"token_preview": cashu_token[:20] + "..."
if len(cashu_token) > 20
else cashu_token
},
)
elif auth := headers.get("authorization", None):
cashu_token = auth.split(" ")[1] if len(auth.split(" ")) > 1 else ""
logger.debug(
"Using Authorization header token",
extra={
"token_preview": cashu_token[:20] + "..."
if len(cashu_token) > 20
else cashu_token
},
)
else:
logger.error("No authentication token provided")
raise HTTPException(status_code=401, detail="Unauthorized")
# Handle empty token
if not cashu_token:
logger.error("Empty token provided")
raise HTTPException(
status_code=401,
detail={
"error": {
"message": "API key or Cashu token required",
"type": "invalid_request_error",
"code": "missing_api_key",
}
},
)
# Handle regular API keys (sk-*)
if cashu_token.startswith("sk-"):
return
token_obj = deserialize_token_from_string(cashu_token)
amount_msat = (
token_obj.amount if token_obj.unit == "msat" else token_obj.amount * 1000
)
if max_cost_for_model - 100 > amount_msat:
raise HTTPException(
status_code=413,
detail={
"reason": "Insufficient balance",
"amount_required_msat": max_cost_for_model,
"model": body.get("model", "unknown"),
"type": "minimum_balance_required",
},
)
def get_max_cost_for_model(model: str) -> int:
"""Get the maximum cost for a specific model."""
logger.debug(
"Getting max cost for model",
extra={
"model": model,
"model_based_pricing": MODEL_BASED_PRICING,
"has_models": bool(MODELS),
},
)
if not MODEL_BASED_PRICING or not MODELS:
logger.debug(
"Using default cost (no model-based pricing)",
extra={"cost_msats": COST_PER_REQUEST, "model": model},
)
return COST_PER_REQUEST
if model not in [model.id for model in MODELS]:
logger.warning(
"Model not found in available models",
extra={
"requested_model": model,
"available_models": [m.id for m in MODELS],
"using_default_cost": COST_PER_REQUEST,
},
)
return COST_PER_REQUEST
for m in MODELS:
if m.id == model:
max_cost = m.sats_pricing.max_cost * 1000 # type: ignore
logger.debug(
"Found model-specific max cost",
extra={"model": model, "max_cost_msats": max_cost},
)
return int(max_cost)
logger.warning(
"Model pricing not found, using default",
extra={"model": model, "default_cost_msats": COST_PER_REQUEST},
)
return COST_PER_REQUEST
def create_error_response(
error_type: str, message: str, status_code: int, token: Optional[str] = None
) -> Response:
"""Create a standardized error response."""
logger.info(
"Creating error response",
extra={
"error_type": error_type,
"error_message": message,
"status_code": status_code,
},
)
response_headers = {}
if token:
response_headers["X-Cashu"] = token
return Response(
content=json.dumps(
{
"error": {
"message": message,
"type": error_type,
"code": status_code,
}
}
),
status_code=status_code,
media_type="application/json",
headers=dict(response_headers),
)
def prepare_upstream_headers(request_headers: dict) -> dict:
"""Prepare headers for upstream request, removing sensitive/problematic ones."""
logger.debug(
"Preparing upstream headers",
extra={
"original_headers_count": len(request_headers),
"has_upstream_api_key": bool(UPSTREAM_API_KEY),
},
)
headers = dict(request_headers)
# Remove headers that shouldn't be forwarded
removed_headers = []
for header in [
"host",
"content-length",
"refund-lnurl",
"key-expiry-time",
"x-cashu",
]:
if headers.pop(header, None) is not None:
removed_headers.append(header)
# Handle authorization
if UPSTREAM_API_KEY:
headers["Authorization"] = f"Bearer {UPSTREAM_API_KEY}"
if headers.pop("authorization", None) is not None:
removed_headers.append("authorization (replaced with upstream key)")
else:
for auth_header in ["Authorization", "authorization"]:
if headers.pop(auth_header, None) is not None:
removed_headers.append(auth_header)
logger.debug(
"Headers prepared for upstream",
extra={
"final_headers_count": len(headers),
"removed_headers": removed_headers,
"added_upstream_auth": bool(UPSTREAM_API_KEY),
},
)
return headers
+130
View File
@@ -0,0 +1,130 @@
import asyncio
import json
import os
from pathlib import Path
from fastapi import APIRouter
from pydantic.v1 import BaseModel
from .price import sats_usd_ask_price
models_router = APIRouter()
class Architecture(BaseModel):
modality: str
input_modalities: list[str]
output_modalities: list[str]
tokenizer: str
instruct_type: str | None
class Pricing(BaseModel):
prompt: float
completion: float
request: float
image: float
web_search: float
internal_reasoning: float
max_cost: float = 0.0 # in sats not msats
class TopProvider(BaseModel):
context_length: int | None = None
max_completion_tokens: int | None = None
is_moderated: bool | None = None
class Model(BaseModel):
id: str
name: str
created: int
description: str
context_length: int
architecture: Architecture
pricing: Pricing
sats_pricing: Pricing | None = None
per_request_limits: dict | None = None
top_provider: TopProvider | None = None
MODELS: list[Model] = []
def load_models() -> list[Model]:
"""Load model definitions from a JSON file.
The file path can be specified via the ``MODELS_PATH`` environment variable.
If ``models.json`` is not found, the bundled ``models.example.json`` is used
as a fallback. If neither file exists or an error occurs while loading, an
empty list is returned.
"""
models_path = Path(os.environ.get("MODELS_PATH", "models.json"))
if not models_path.exists():
example = Path(__file__).resolve().parent.parent / "models.example.json"
if example.exists():
models_path = example
else:
return []
try:
with models_path.open("r") as f:
data = json.load(f)
except Exception as e: # pragma: no cover - log and continue
print(f"Error loading models from {models_path}: {e}")
return []
return [Model(**model) for model in data.get("models", [])]
MODELS = load_models()
async def update_sats_pricing() -> None:
while True:
try:
sats_to_usd = await sats_usd_ask_price()
for model in MODELS:
model.sats_pricing = Pricing(
**{k: v / sats_to_usd for k, v in model.pricing.dict().items()}
)
mspp = model.sats_pricing.prompt
mspc = model.sats_pricing.completion
if model.top_provider:
if (cl := model.top_provider.context_length) and (
mct := model.top_provider.max_completion_tokens
):
model.sats_pricing.max_cost = (cl - mct) * mspp + mct * mspc
elif cl := model.top_provider.context_length:
model.sats_pricing.max_cost = cl * 0.8 * mspp + cl * 0.2 * mspc
elif mct := model.top_provider.max_completion_tokens:
model.sats_pricing.max_cost = mct * 4 * mspp + mct * mspc
else:
model.sats_pricing.max_cost = 1_000_000 * mspp + 32_000 * mspc
elif model.context_length:
model.sats_pricing.max_cost = (
model.sats_pricing.prompt * model.context_length * 0.8
) + (model.sats_pricing.completion * model.context_length * 0.2)
else:
p = model.sats_pricing.prompt * 1_000_000
c = model.sats_pricing.completion * 32_000
r = model.sats_pricing.request * 100_000
i = model.sats_pricing.image * 100
w = model.sats_pricing.web_search * 1000
ir = model.sats_pricing.internal_reasoning * 100
model.sats_pricing.max_cost = p + c + r + i + w + ir
except asyncio.CancelledError:
break
except Exception as e:
print("Error updating sats pricing: ", e)
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
break
@models_router.get("/v1/models")
@models_router.get("/models")
async def models() -> dict:
return {"data": MODELS}
+129
View File
@@ -0,0 +1,129 @@
import asyncio
import os
import httpx
from ..core import get_logger
logger = get_logger(__name__)
# artifical spread to cover conversion fees
EXCHANGE_FEE = float(os.environ.get("EXCHANGE_FEE", "1.005")) # 0.5% default
UPSTREAM_PROVIDER_FEE = float(
os.environ.get("UPSTREAM_PROVIDER_FEE", "1.05")
) # 5% default (e.g. openrouter charges 5% margin)
async def kraken_btc_usd(client: httpx.AsyncClient) -> float | None:
"""Fetch BTC/USD price from Kraken API."""
api = "https://api.kraken.com/0/public/Ticker?pair=XBTUSD"
try:
logger.debug("Fetching BTC price from Kraken")
response = await client.get(api)
price_data = response.json()
price = float(price_data["result"]["XXBTZUSD"]["c"][0])
return price
except (httpx.RequestError, KeyError) as e:
logger.warning(
"Kraken API error",
extra={
"error": str(e),
"error_type": type(e).__name__,
"exchange": "kraken",
},
)
return None
async def coinbase_btc_usd(client: httpx.AsyncClient) -> float | None:
"""Fetch BTC/USD price from Coinbase API."""
api = "https://api.coinbase.com/v2/prices/BTC-USD/spot"
try:
logger.debug("Fetching BTC price from Coinbase")
response = await client.get(api)
price_data = response.json()
price = float(price_data["data"]["amount"])
return price
except (httpx.RequestError, KeyError) as e:
logger.warning(
"Coinbase API error",
extra={
"error": str(e),
"error_type": type(e).__name__,
"exchange": "coinbase",
},
)
return None
async def binance_btc_usdt(client: httpx.AsyncClient) -> float | None:
"""Fetch BTC/USDT price from Binance API."""
api = "https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT"
try:
logger.debug("Fetching BTC price from Binance")
response = await client.get(api)
price_data = response.json()
price = float(price_data["price"])
return price
except (httpx.RequestError, KeyError) as e:
logger.warning(
"Binance API error",
extra={
"error": str(e),
"error_type": type(e).__name__,
"exchange": "binance",
},
)
return None
async def btc_usd_ask_price() -> float:
"""Get the highest BTC/USD price from multiple exchanges with fee adjustment."""
logger.debug("Starting BTC price aggregation from multiple exchanges")
async with httpx.AsyncClient(timeout=30.0) as client:
try:
prices = await asyncio.gather(
kraken_btc_usd(client),
coinbase_btc_usd(client),
binance_btc_usdt(client),
)
valid_prices = [price for price in prices if price is not None]
if not valid_prices:
logger.error("No valid BTC prices obtained from any exchange")
raise ValueError("Unable to fetch BTC price from any exchange")
max_price = max(valid_prices)
final_price = max_price * EXCHANGE_FEE * UPSTREAM_PROVIDER_FEE
return final_price
except Exception as e:
logger.error(
"Error in BTC price aggregation",
extra={"error": str(e), "error_type": type(e).__name__},
)
raise
async def sats_usd_ask_price() -> float:
"""Get the USD price per satoshi."""
logger.debug("Calculating satoshi price from BTC price")
try:
btc_price = await btc_usd_ask_price()
sats_price = btc_price / 100_000_000
return sats_price
except Exception as e:
logger.error(
"Error calculating satoshi price",
extra={"error": str(e), "error_type": type(e).__name__},
)
raise
+632
View File
@@ -0,0 +1,632 @@
import json
import traceback
from typing import AsyncGenerator
import httpx
from fastapi import BackgroundTasks, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from ..core import get_logger
from ..wallet import CurrencyUnit, 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,
)
logger = get_logger(__name__)
async def x_cashu_handler(
request: Request, x_cashu_token: str, path: str
) -> Response | StreamingResponse:
"""Handle X-Cashu token payment requests."""
logger.info(
"Processing X-Cashu payment request",
extra={
"path": path,
"method": request.method,
"token_preview": x_cashu_token[:20] + "..."
if len(x_cashu_token) > 20
else x_cashu_token,
},
)
try:
headers = dict(request.headers)
amount, unit, mint = await recieve_token(x_cashu_token)
headers = prepare_upstream_headers(dict(request.headers))
logger.info(
"X-Cashu token redeemed successfully",
extra={"amount": amount, "unit": unit, "path": path, "mint": mint},
)
return await forward_to_upstream(request, path, headers, amount, unit)
except Exception as e:
error_message = str(e)
logger.error(
"X-Cashu payment request failed",
extra={
"error": error_message,
"error_type": type(e).__name__,
"path": path,
"method": request.method,
},
)
# Handle specific CASHU errors with appropriate HTTP status codes
if "already spent" in error_message.lower():
return create_error_response(
"token_already_spent",
"The provided CASHU token has already been spent",
400,
x_cashu_token,
)
if "invalid token" in error_message.lower():
return create_error_response(
"invalid_token",
"The provided CASHU token is invalid",
400,
x_cashu_token,
)
if "mint error" in error_message.lower():
return create_error_response(
"mint_error", f"CASHU mint error: {error_message}", 422, x_cashu_token
)
# Generic error for other cases
return create_error_response(
"cashu_error",
f"CASHU token processing failed: {error_message}",
400,
x_cashu_token,
)
async def forward_to_upstream(
request: Request, path: str, headers: dict, amount: int, unit: CurrencyUnit
) -> Response | StreamingResponse:
"""Forward request to upstream and handle the response."""
if path.startswith("v1/"):
path = path.replace("v1/", "")
url = f"{UPSTREAM_BASE_URL}/{path}"
logger.debug(
"Forwarding request to upstream",
extra={
"url": url,
"method": request.method,
"path": path,
"amount": amount,
"unit": unit,
},
)
async with httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=1),
timeout=None,
) as client:
try:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=request.stream(),
params=request.query_params,
),
stream=True,
)
logger.debug(
"Received upstream response",
extra={
"status_code": response.status_code,
"path": path,
"response_headers": dict(response.headers),
},
)
if response.status_code != 200:
logger.warning(
"Upstream request failed, processing refund",
extra={
"status_code": response.status_code,
"path": path,
"amount": amount,
"unit": unit,
},
)
refund_token = await send_refund(amount - 60, unit)
logger.info(
"Refund processed for failed upstream request",
extra={
"status_code": response.status_code,
"refund_amount": amount,
"unit": unit,
"refund_token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
},
)
error_response = Response(
content=json.dumps(
{
"error": {
"message": "Error forwarding request to upstream",
"type": "upstream_error",
"code": response.status_code,
"refund_token": refund_token,
}
}
),
status_code=response.status_code,
media_type="application/json",
)
error_response.headers["X-Cashu"] = refund_token
return error_response
if path.endswith("chat/completions"):
logger.debug(
"Processing chat completion response",
extra={"path": path, "amount": amount, "unit": unit},
)
result = await handle_x_cashu_chat_completion(response, amount, unit)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
result.background = background_tasks
return result
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
logger.debug(
"Streaming non-chat response",
extra={"path": path, "status_code": response.status_code},
)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
background=background_tasks,
)
except Exception as exc:
tb = traceback.format_exc()
logger.error(
"Unexpected error in upstream forwarding",
extra={
"error": str(exc),
"error_type": type(exc).__name__,
"method": request.method,
"url": url,
"path": path,
"query_params": dict(request.query_params),
"traceback": tb,
},
)
return create_error_response(
"internal_error", "An unexpected server error occurred", 500
)
async def handle_x_cashu_chat_completion(
response: httpx.Response, amount: int, unit: CurrencyUnit
) -> StreamingResponse | Response:
"""Handle both streaming and non-streaming chat completion responses with token-based pricing."""
logger.debug(
"Handling chat completion response",
extra={"amount": amount, "unit": unit, "status_code": response.status_code},
)
try:
content = await response.aread()
content_str = content.decode("utf-8") if isinstance(content, bytes) else content
is_streaming = content_str.startswith("data:") or "data:" in content_str
logger.debug(
"Chat completion response analysis",
extra={
"is_streaming": is_streaming,
"content_length": len(content_str),
"amount": amount,
"unit": unit,
},
)
if is_streaming:
return await handle_streaming_response(content_str, response, amount, unit)
else:
return await handle_non_streaming_response(
content_str, response, amount, unit
)
except Exception as e:
logger.error(
"Error processing chat completion response",
extra={
"error": str(e),
"error_type": type(e).__name__,
"amount": amount,
"unit": unit,
},
)
# Return the original response if we can't process it
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
)
async def handle_streaming_response(
content_str: str, response: httpx.Response, amount: int, unit: CurrencyUnit
) -> StreamingResponse:
"""Handle Server-Sent Events (SSE) streaming response."""
logger.debug(
"Processing streaming response",
extra={
"amount": amount,
"unit": unit,
"content_lines": len(content_str.strip().split("\n")),
},
)
# Initialize response headers early so they can be modified during processing
response_headers = dict(response.headers)
if "transfer-encoding" in response_headers:
del response_headers["transfer-encoding"]
if "content-encoding" in response_headers:
del response_headers["content-encoding"]
# For streaming responses, we'll extract the final usage data
# and calculate cost based on that
usage_data = None
model = None
# Parse SSE format to extract usage information
lines = content_str.strip().split("\n")
for line in lines:
if line.startswith("data: "):
try:
data_json = json.loads(line[6:]) # Remove 'data: ' prefix
# Look for usage information in the final chunks
if "usage" in data_json:
usage_data = data_json["usage"]
model = data_json.get("model")
elif "model" in data_json and not model:
model = data_json["model"]
except json.JSONDecodeError:
continue
response_headers = dict(response.headers)
# If we found usage data, calculate cost and refund
if usage_data and model:
logger.debug(
"Found usage data in streaming response",
extra={
"model": model,
"usage_data": usage_data,
"amount": amount,
"unit": unit,
},
)
response_data = {"usage": usage_data, "model": model}
try:
cost_data = await get_cost(response_data)
if cost_data:
if unit == "msat":
refund_amount = amount - cost_data.total_msats
elif unit == "sat":
refund_amount = amount - (cost_data.total_msats + 999) // 1000
else:
raise ValueError(f"Invalid unit: {unit}")
if refund_amount > 0:
logger.info(
"Processing refund for streaming response",
extra={
"original_amount": amount,
"cost_msats": cost_data.total_msats,
"refund_amount": refund_amount,
"unit": unit,
"model": model,
},
)
refund_token = await send_refund(refund_amount, unit)
response_headers["X-Cashu"] = refund_token
logger.info(
"Refund processed for streaming response",
extra={
"refund_amount": refund_amount,
"unit": unit,
"refund_token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
},
)
else:
logger.debug(
"No refund needed for streaming response",
extra={
"amount": amount,
"cost_msats": cost_data.total_msats,
"model": model,
},
)
except Exception as e:
logger.error(
"Error calculating cost for streaming response",
extra={
"error": str(e),
"error_type": type(e).__name__,
"model": model,
"amount": amount,
"unit": unit,
},
)
async def generate() -> AsyncGenerator[bytes, None]:
for line in lines:
yield (line + "\n").encode("utf-8")
return StreamingResponse(
generate(),
status_code=response.status_code,
headers=response_headers,
media_type="text/plain",
)
async def handle_non_streaming_response(
content_str: str, response: httpx.Response, amount: int, unit: CurrencyUnit
) -> Response:
"""Handle regular JSON response."""
logger.debug(
"Processing non-streaming response",
extra={"amount": amount, "unit": unit, "content_length": len(content_str)},
)
try:
response_json = json.loads(content_str)
cost_data = await get_cost(response_json)
if not cost_data:
logger.error(
"Failed to calculate cost for response",
extra={
"amount": amount,
"unit": unit,
"response_model": response_json.get("model", "unknown"),
},
)
return Response(
content=json.dumps(
{
"error": {
"message": "Error forwarding request to upstream",
"type": "upstream_error",
"code": response.status_code,
}
}
),
status_code=response.status_code,
media_type="application/json",
)
response_headers = dict(response.headers)
if "transfer-encoding" in response_headers:
del response_headers["transfer-encoding"]
if "content-encoding" in response_headers:
del response_headers["content-encoding"]
if unit == "msat":
refund_amount = amount - cost_data.total_msats
elif unit == "sat":
refund_amount = amount - (cost_data.total_msats + 999) // 1000
else:
raise ValueError(f"Invalid unit: {unit}")
logger.info(
"Processing non-streaming response cost calculation",
extra={
"original_amount": amount,
"cost_msats": cost_data.total_msats,
"refund_amount": refund_amount,
"unit": unit,
"model": response_json.get("model", "unknown"),
},
)
if refund_amount > 0:
refund_token = await send_refund(refund_amount, unit)
response_headers["X-Cashu"] = refund_token
logger.info(
"Refund processed for non-streaming response",
extra={
"refund_amount": refund_amount,
"unit": unit,
"refund_token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
},
)
return Response(
content=content_str,
status_code=response.status_code,
headers=response_headers,
media_type="application/json",
)
except json.JSONDecodeError as e:
logger.error(
"Failed to parse JSON from upstream response",
extra={
"error": str(e),
"content_preview": content_str[:200] + "..."
if len(content_str) > 200
else content_str,
"amount": amount,
"unit": unit,
},
)
# Emergency refund with small deduction for processing
emergency_refund = amount
refund_token = await send_token(emergency_refund, unit=unit)
response.headers["X-Cashu"] = refund_token
logger.warning(
"Emergency refund issued due to JSON parse error",
extra={
"original_amount": amount,
"refund_amount": emergency_refund,
"deduction": 60,
},
)
# Return original content if JSON parsing fails
return Response(
content=content_str,
status_code=response.status_code,
headers=dict(response.headers),
media_type="application/json",
)
async def get_cost(response_data: dict) -> 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")
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):
case MaxCostData() as cost:
logger.debug(
"Using max cost pricing",
extra={"model": model, "max_cost_msats": cost.total_msats},
)
return cost
case CostData() as cost:
logger.debug(
"Using token-based pricing",
extra={
"model": model,
"total_cost_msats": cost.total_msats,
"input_msats": cost.input_msats,
"output_msats": cost.output_msats,
},
)
return cost
case CostDataError() as error:
logger.error(
"Cost calculation error",
extra={
"model": model,
"error_message": error.message,
"error_code": error.code,
},
)
raise HTTPException(
status_code=400,
detail={
"error": {
"message": error.message,
"type": "invalid_request_error",
"code": error.code,
}
},
)
async def send_refund(amount: int, unit: CurrencyUnit, mint: str | None = None) -> str:
"""Send a refund using Cashu tokens."""
logger.debug(
"Creating refund token", extra={"amount": amount, "unit": unit, "mint": mint}
)
max_retries = 3
last_exception = None
for attempt in range(max_retries):
try:
refund_token = await send_token(amount, unit=unit, mint_url=mint)
logger.info(
"Refund token created successfully",
extra={
"amount": amount,
"unit": unit,
"mint": mint,
"attempt": attempt + 1,
"token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
},
)
return refund_token
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
logger.warning(
"Refund token creation failed, retrying",
extra={
"error": str(e),
"error_type": type(e).__name__,
"attempt": attempt + 1,
"max_retries": max_retries,
"amount": amount,
"unit": unit,
"mint": mint,
},
)
else:
logger.error(
"Failed to create refund token after all retries",
extra={
"error": str(e),
"error_type": type(e).__name__,
"attempt": attempt + 1,
"max_retries": max_retries,
"amount": amount,
"unit": unit,
"mint": mint,
},
)
# If we get here, all retries failed
raise HTTPException(
status_code=401,
detail={
"error": {
"message": f"failed to create refund after {max_retries} attempts: {str(last_exception)}",
"type": "invalid_request_error",
"code": "send_token_failed",
}
},
)
+733
View File
@@ -0,0 +1,733 @@
import json
import re
import traceback
from typing import AsyncGenerator
import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from .auth import (
adjust_payment_for_tokens,
pay_for_request,
revert_pay_for_request,
validate_bearer_key,
)
from .core import get_logger
from .core.db import ApiKey, AsyncSession, create_session, get_session
from .payment.helpers import (
UPSTREAM_BASE_URL,
check_token_balance,
create_error_response,
get_cost_per_request,
prepare_upstream_headers,
)
from .payment.x_cashu import x_cashu_handler
logger = get_logger(__name__)
proxy_router = APIRouter()
async def handle_streaming_chat_completion(
response: httpx.Response, key: ApiKey, max_cost_for_model: int
) -> StreamingResponse:
"""Handle streaming chat completion responses with token-based pricing."""
logger.info(
"Processing streaming chat completion",
extra={
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
"response_status": response.status_code,
},
)
async def stream_with_cost(max_cost_for_model: int) -> AsyncGenerator[bytes, None]:
# Store all chunks to analyze
stored_chunks = []
async for chunk in response.aiter_bytes():
# Store chunk for later analysis
stored_chunks.append(chunk)
# Pass through each chunk to client
yield chunk
logger.debug(
"Streaming completed, analyzing usage data",
extra={
"key_hash": key.hashed_key[:8] + "...",
"chunks_count": len(stored_chunks),
},
)
# Process stored chunks to find usage data
# Start from the end and work backwards
for i in range(len(stored_chunks) - 1, -1, -1):
chunk = stored_chunks[i]
if not chunk or chunk == b"":
continue
try:
# Split by "data: " to get individual SSE events
events = re.split(b"data: ", chunk)
for event_data in events:
if (
not event_data
or event_data.strip() == b"[DONE]"
or event_data.strip() == b""
):
continue
try:
data = json.loads(event_data)
if (
"usage" in data
and data["usage"] is not None
and isinstance(data["usage"], dict)
):
logger.info(
"Found usage data in streaming response",
extra={
"key_hash": key.hashed_key[:8] + "...",
"usage_data": data["usage"],
"model": data.get("model", "unknown"),
},
)
# Found usage data, calculate cost
# Create a new session for this operation
async with create_session() as new_session:
# Re-fetch the key in the new session
fresh_key = await new_session.get(
key.__class__, key.hashed_key
)
if fresh_key:
try:
cost_data = await adjust_payment_for_tokens(
fresh_key,
data,
new_session,
max_cost_for_model,
)
logger.info(
"Token adjustment completed for streaming",
extra={
"key_hash": key.hashed_key[:8] + "...",
"cost_data": cost_data,
"balance_after_adjustment": fresh_key.balance,
},
)
# Format as SSE and yield
cost_json = json.dumps({"cost": cost_data})
yield f"data: {cost_json}\n\n".encode()
except Exception as cost_error:
logger.error(
"Error adjusting payment for streaming tokens",
extra={
"error": str(cost_error),
"error_type": type(cost_error).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
break
except json.JSONDecodeError:
continue
except Exception as e:
logger.error(
"Error processing streaming response chunk",
extra={
"error": str(e),
"error_type": type(e).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
return StreamingResponse(
stream_with_cost(max_cost_for_model),
status_code=response.status_code,
headers=dict(response.headers),
)
async def handle_non_streaming_chat_completion(
response: httpx.Response,
key: ApiKey,
session: AsyncSession,
deducted_max_cost: int,
) -> Response:
"""Handle non-streaming chat completion responses with token-based pricing."""
logger.info(
"Processing non-streaming chat completion",
extra={
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
"response_status": response.status_code,
},
)
try:
content = await response.aread()
response_json = json.loads(content)
logger.debug(
"Parsed response JSON",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": response_json.get("model", "unknown"),
"has_usage": "usage" in response_json,
},
)
cost_data = await adjust_payment_for_tokens(
key, response_json, session, deducted_max_cost
)
response_json["cost"] = cost_data
logger.info(
"Token adjustment completed for non-streaming",
extra={
"key_hash": key.hashed_key[:8] + "...",
"cost_data": cost_data,
"model": response_json.get("model", "unknown"),
"balance_after_adjustment": key.balance,
},
)
# Keep only standard headers that are safe to pass through
allowed_headers = {
"content-type",
"cache-control",
"date",
"vary",
"access-control-allow-origin",
"access-control-allow-methods",
"access-control-allow-headers",
"access-control-allow-credentials",
"access-control-expose-headers",
"access-control-max-age",
}
response_headers = {
k: v for k, v in response.headers.items() if k.lower() in allowed_headers
}
return Response(
content=json.dumps(response_json).encode(),
status_code=response.status_code,
headers=response_headers,
media_type="application/json",
)
except json.JSONDecodeError as e:
logger.error(
"Failed to parse JSON from upstream response",
extra={
"error": str(e),
"key_hash": key.hashed_key[:8] + "...",
"content_preview": content[:200].decode(errors="ignore")
if content
else "empty",
},
)
raise
except Exception as e:
logger.error(
"Error processing non-streaming chat completion",
extra={
"error": str(e),
"error_type": type(e).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
async def forward_to_upstream(
request: Request,
path: str,
headers: dict,
request_body: bytes | None,
key: ApiKey,
max_cost_for_model: int,
session: AsyncSession,
) -> Response | StreamingResponse:
"""Forward request to upstream and handle the response."""
if path.startswith("v1/"):
path = path.replace("v1/", "")
url = f"{UPSTREAM_BASE_URL}/{path}"
logger.info(
"Forwarding request to upstream",
extra={
"url": url,
"method": request.method,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
"has_request_body": request_body is not None,
},
)
client = httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=1),
timeout=None, # No timeout - requests can take as long as needed
)
try:
# Use the pre-read body if available, otherwise stream
if request_body is not None:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=request_body,
params=request.query_params,
),
stream=True,
)
else:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=request.stream(),
params=request.query_params,
),
stream=True,
)
logger.info(
"Received upstream response",
extra={
"status_code": response.status_code,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"content_type": response.headers.get("content-type", "unknown"),
},
)
# For chat completions, we need to handle token-based pricing
if path.endswith("chat/completions"):
# Check if client requested streaming
client_wants_streaming = False
if request_body:
try:
request_data = json.loads(request_body)
client_wants_streaming = request_data.get("stream", False)
logger.debug(
"Chat completion request analysis",
extra={
"client_wants_streaming": client_wants_streaming,
"model": request_data.get("model", "unknown"),
"key_hash": key.hashed_key[:8] + "...",
},
)
except json.JSONDecodeError:
logger.warning(
"Failed to parse request body JSON for streaming detection"
)
# Handle both streaming and non-streaming responses
content_type = response.headers.get("content-type", "")
upstream_is_streaming = "text/event-stream" in content_type
is_streaming = client_wants_streaming and upstream_is_streaming
logger.debug(
"Response type analysis",
extra={
"is_streaming": is_streaming,
"client_wants_streaming": client_wants_streaming,
"upstream_is_streaming": upstream_is_streaming,
"content_type": content_type,
"key_hash": key.hashed_key[:8] + "...",
},
)
if is_streaming and response.status_code == 200:
# Process streaming response and extract cost from the last chunk
result = await handle_streaming_chat_completion(
response, key, max_cost_for_model
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
result.background = background_tasks
return result
elif response.status_code == 200:
# Handle non-streaming response
try:
return await handle_non_streaming_chat_completion(
response, key, session, max_cost_for_model
)
finally:
await response.aclose()
await client.aclose()
# For all other responses, stream the response
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
logger.debug(
"Streaming non-chat response",
extra={
"path": path,
"status_code": response.status_code,
"key_hash": key.hashed_key[:8] + "...",
},
)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
background=background_tasks,
)
except httpx.RequestError as exc:
await client.aclose()
error_type = type(exc).__name__
error_details = str(exc)
logger.error(
"HTTP request error to upstream",
extra={
"error_type": error_type,
"error_details": error_details,
"method": request.method,
"url": url,
"path": path,
"query_params": dict(request.query_params),
"key_hash": key.hashed_key[:8] + "...",
},
)
# Provide more specific error messages based on the error type
if isinstance(exc, httpx.ConnectError):
error_message = "Unable to connect to upstream service"
elif isinstance(exc, httpx.TimeoutException):
error_message = "Upstream service request timed out"
elif isinstance(exc, httpx.NetworkError):
error_message = "Network error while connecting to upstream service"
else:
error_message = f"Error connecting to upstream service: {error_type}"
return create_error_response("upstream_error", error_message, 502)
except Exception as exc:
await client.aclose()
tb = traceback.format_exc()
logger.error(
"Unexpected error in upstream forwarding",
extra={
"error": str(exc),
"error_type": type(exc).__name__,
"method": request.method,
"url": url,
"path": path,
"query_params": dict(request.query_params),
"key_hash": key.hashed_key[:8] + "...",
"traceback": tb,
},
)
return create_error_response(
"internal_error", "An unexpected server error occurred", 500
)
@proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None)
async def proxy(
request: Request, path: str, session: AsyncSession = Depends(get_session)
) -> Response | StreamingResponse:
"""Main proxy endpoint handler."""
logger.info(
"Received proxy request",
extra={
"method": request.method,
"path": path,
"client_host": request.client.host if request.client else "unknown",
"user_agent": request.headers.get("user-agent", "unknown")[:100],
},
)
request_body = await request.body()
headers = dict(request.headers)
# Parse JSON body if present, handle empty/invalid JSON
request_body_dict = {}
if request_body:
try:
request_body_dict = json.loads(request_body)
logger.debug(
"Request body parsed",
extra={
"path": path,
"body_keys": list(request_body_dict.keys()),
"model": request_body_dict.get("model", "not_specified"),
},
)
except json.JSONDecodeError as e:
logger.error(
"Invalid JSON in request body",
extra={
"error": str(e),
"path": path,
"body_preview": request_body[:200].decode(errors="ignore")
if request_body
else "empty",
},
)
return Response(
content=json.dumps(
{"error": {"type": "invalid_request_error", "code": "invalid_json"}}
),
status_code=400,
media_type="application/json",
)
max_cost_for_model = get_cost_per_request(
model=request_body_dict.get("model", None)
)
check_token_balance(headers, request_body_dict, max_cost_for_model)
# Handle authentication
if x_cashu := headers.get("x-cashu", None):
logger.info(
"Processing X-Cashu payment",
extra={
"path": path,
"token_preview": x_cashu[:20] + "..." if len(x_cashu) > 20 else x_cashu,
},
)
return await x_cashu_handler(request, x_cashu, path)
elif auth := headers.get("authorization", None):
logger.debug(
"Processing bearer token authentication",
extra={
"path": path,
"token_preview": auth[:20] + "..." if len(auth) > 20 else auth,
},
)
key = await get_bearer_token_key(headers, path, session, auth)
else:
if request.method not in ["GET"]:
logger.warning(
"Unauthorized request - no authentication provided",
extra={"method": request.method, "path": path},
)
return Response(
content=json.dumps({"detail": "Unauthorized"}),
status_code=401,
media_type="application/json",
)
logger.debug("Processing unauthenticated GET request", extra={"path": path})
# Prepare headers for upstream
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(
"Processing payment for request",
extra={
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"key_balance_before": key.balance,
"model": request_body_dict.get("model", "unknown"),
},
)
try:
await pay_for_request(key, session, request_body_dict)
logger.info(
"Payment processed successfully",
extra={
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"key_balance_after": key.balance,
"model": request_body_dict.get("model", "unknown"),
},
)
except Exception as e:
logger.error(
"Payment processing failed",
extra={
"error": str(e),
"error_type": type(e).__name__,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
# Prepare headers for upstream
headers = prepare_upstream_headers(dict(request.headers))
# Forward to upstream and handle response
response = await forward_to_upstream(
request, path, headers, request_body, key, max_cost_for_model, session
)
if response.status_code != 200:
await revert_pay_for_request(key, session, cost_per_request)
logger.warning(
"Upstream request failed, revert payment",
extra={
"status_code": response.status_code,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
},
)
return response
async def get_bearer_token_key(
headers: dict, path: str, session: AsyncSession, auth: str
) -> ApiKey:
"""Handle bearer token authentication proxy requests."""
bearer_key = auth.replace("Bearer ", "") if auth.startswith("Bearer ") else ""
refund_address = headers.get("Refund-LNURL", None)
key_expiry_time = headers.get("Key-Expiry-Time", None)
logger.debug(
"Processing bearer token",
extra={
"path": path,
"has_refund_address": bool(refund_address),
"has_expiry_time": bool(key_expiry_time),
"bearer_key_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
},
)
# Validate key_expiry_time header
if key_expiry_time:
try:
key_expiry_time = int(key_expiry_time) # type: ignore
logger.debug(
"Key expiry time validated",
extra={"expiry_time": key_expiry_time, "path": path},
)
except ValueError:
logger.error(
"Invalid Key-Expiry-Time header",
extra={"key_expiry_time": key_expiry_time, "path": path},
)
raise HTTPException(
status_code=400,
detail="Invalid Key-Expiry-Time: must be a valid Unix timestamp",
)
if not refund_address:
logger.error(
"Missing Refund-LNURL header with Key-Expiry-Time",
extra={"path": path, "expiry_time": key_expiry_time},
)
raise HTTPException(
status_code=400,
detail="Error: Refund-LNURL header required when using Key-Expiry-Time",
)
else:
key_expiry_time = None
try:
key = await validate_bearer_key(
bearer_key,
session,
refund_address,
key_expiry_time, # type: ignore
)
logger.info(
"Bearer token validated successfully",
extra={
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
},
)
return key
except Exception as e:
logger.error(
"Bearer token validation failed",
extra={
"error": str(e),
"error_type": type(e).__name__,
"path": path,
"bearer_key_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
},
)
raise
async def forward_get_to_upstream(
request: Request,
path: str,
headers: dict,
) -> Response | StreamingResponse:
"""Forward request to upstream and handle the response."""
if path.startswith("v1/"):
path = path.replace("v1/", "")
url = f"{UPSTREAM_BASE_URL}/{path}"
logger.info(
"Forwarding GET request to upstream",
extra={"url": url, "method": request.method, "path": path},
)
async with httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=1),
timeout=None,
) as client:
try:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=request.stream(),
params=request.query_params,
),
)
logger.info(
"GET request forwarded successfully",
extra={"path": path, "status_code": response.status_code},
)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
)
except Exception as exc:
tb = traceback.format_exc()
logger.error(
"Error forwarding GET request",
extra={
"error": str(exc),
"error_type": type(exc).__name__,
"method": request.method,
"url": url,
"path": path,
"query_params": dict(request.query_params),
"traceback": tb,
},
)
return create_error_response(
"internal_error", "An unexpected server error occurred", 500
)
+166
View File
@@ -0,0 +1,166 @@
import os
from typing import Literal
from cashu.core.base import Token
from cashu.wallet.helpers import deserialize_token_from_string, receive, send
from cashu.wallet.wallet import Wallet
from .core import db, get_logger
logger = get_logger(__name__)
CurrencyUnit = Literal["sat", "msat"]
TRUSTED_MINTS = os.environ["CASHU_MINTS"].split(",")
PRIMARY_MINT_URL = TRUSTED_MINTS[0]
async def get_balance(unit: CurrencyUnit) -> int:
wallet = await Wallet.with_db(
PRIMARY_MINT_URL,
db=".wallet",
load_all_keysets=True,
unit=unit,
)
await wallet.load_proofs()
return wallet.available_balance.amount
async def recieve_token(
token: str,
) -> tuple[int, CurrencyUnit, str]: # amount, unit, mint_url
print(token)
token_obj = deserialize_token_from_string(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])
if token_obj.mint not in TRUSTED_MINTS:
return await swap_to_primary_mint(token_obj, wallet)
await receive(wallet, token_obj)
return token_obj.amount, token_obj.unit, token_obj.mint
async def send_token(
amount: int, unit: CurrencyUnit, mint_url: str | None = None
) -> str:
wallet = await Wallet.with_db(
mint_url or PRIMARY_MINT_URL,
db=".wallet",
load_all_keysets=True,
unit=unit,
)
balance, token = await send(wallet, amount=amount, lock="", legacy=False)
return token
async def swap_to_primary_mint(
token_obj: Token, token_wallet: Wallet
) -> tuple[int, CurrencyUnit, str]:
logger.info(
"swap_to_primary_mint",
extra={
"mint": token_obj.mint,
"amount": token_obj.amount,
"unit": token_obj.unit,
},
)
if token_obj.unit == "sat":
amount_msat = token_obj.amount * 1000
elif token_obj.unit == "msat":
amount_msat = token_obj.amount
else:
raise ValueError("Invalid unit")
estimated_fee_sat = 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()
minted_amount = amount_msat_after_fee // 1000
mint_quote = await primary_wallet.request_mint(minted_amount)
melt_quote = await token_wallet.melt_quote(mint_quote.request)
_ = await token_wallet.melt(
proofs=token_obj.proofs,
invoice=mint_quote.request,
fee_reserve_sat=melt_quote.fee_reserve,
quote_id=melt_quote.quote,
)
_ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote)
return minted_amount, "sat", PRIMARY_MINT_URL
async def credit_balance(
cashu_token: str, key: db.ApiKey, session: db.AsyncSession
) -> int:
amount, unit, mint_url = await recieve_token(cashu_token)
if unit == "sat":
amount = amount * 1000
if mint_url != PRIMARY_MINT_URL:
raise ValueError("Mint URL is not supported by this proxy")
key.balance += amount
session.add(key)
await session.commit()
logger.info(
"Cashu token successfully redeemed and stored",
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
)
return amount
async def send_to_lnurl(amount: int, unit: CurrencyUnit, lnurl: str) -> dict[str, int]:
raise NotImplementedError
async def check_for_refunds() -> None:
logger.warning("check_for_refunds, temporary not implemented")
async def periodic_payout() -> None:
logger.warning("periodic_payout, temporary not implemented")
# class Proof:
# """
# Represents an ecash bill
# """
# def redeem_to_proofs(self, token: str) -> list[Proof]:
# raise NotImplementedError
# class Payment:
# """
# Stores all cashu payment related data
# """
# def __init__(self, token: str) -> None:
# self.initial_token = token
# amount, unit, mint_url = self.parse_token(token)
# self.amount = amount
# self.unit = unit
# self.mint_url = mint_url
# self.claimed_proofs = redeem_to_proofs(token)
# def parse_token(self, token: str) -> tuple[int, CurrencyUnit, str]:
# raise NotImplementedError
# def refund_full(self) -> None:
# raise NotImplementedError
# def refund_partial(self, amount: int) -> None:
# raise NotImplementedError
-246
View File
@@ -1,246 +0,0 @@
"""Model prioritization algorithm for selecting cheapest upstream providers."""
from typing import TYPE_CHECKING
from .core.logging import get_logger
if TYPE_CHECKING:
from .payment.models import Model
from .upstream import BaseUpstreamProvider
logger = get_logger(__name__)
def calculate_model_cost_score(model: "Model") -> float:
"""Calculate a representative cost score for a model.
This score is used to compare models when multiple providers offer the same model.
Lower scores indicate cheaper models.
The score is calculated as a weighted average of:
- Input token cost (weighted by typical input usage)
- Output token cost (weighted by typical output usage)
- Fixed request cost
Args:
model: Model instance with pricing information
Returns:
Float representing the cost score. Lower is better.
"""
pricing = model.pricing
# Weight costs by typical usage patterns
# Assume average request: 1000 input tokens, 500 output tokens
TYPICAL_INPUT_TOKENS = 1000.0
TYPICAL_OUTPUT_TOKENS = 500.0
# Calculate weighted cost in USD
input_cost = pricing.prompt * (TYPICAL_INPUT_TOKENS / 1000.0)
output_cost = pricing.completion * (TYPICAL_OUTPUT_TOKENS / 1000.0)
request_cost = pricing.request
# Include additional costs if present
image_cost = (
getattr(pricing, "image", 0.0) * 0.1
) # Weight lower as not every request uses images
web_search_cost = getattr(pricing, "web_search", 0.0) * 0.1
reasoning_cost = getattr(pricing, "internal_reasoning", 0.0) * 0.2
total_cost = (
input_cost
+ output_cost
+ request_cost
+ image_cost
+ web_search_cost
+ reasoning_cost
)
return total_cost
def get_provider_penalty(provider: "BaseUpstreamProvider") -> float:
"""Calculate a penalty multiplier for certain providers.
This allows applying policy-based adjustments beyond pure cost.
For example, preferring certain providers for reliability or features.
Args:
provider: UpstreamProvider instance
Returns:
Float multiplier to apply to cost (1.0 = no penalty, >1.0 = penalize)
"""
# Default: no penalty
penalty = 1.0
# Check if this is OpenRouter (can be identified by base URL)
base_url = getattr(provider, "base_url", "")
if "openrouter.ai" in base_url.lower():
# Small penalty for OpenRouter to prefer other providers when costs are very close
# This maintains the original behavior of preferring non-OpenRouter providers
penalty = 1.001 # 0.1% penalty
return penalty
def create_model_mappings(
upstreams: list["BaseUpstreamProvider"],
overrides_by_id: dict[str, tuple],
disabled_model_ids: set[str],
) -> tuple[
dict[str, "Model"], dict[str, list["BaseUpstreamProvider"]], dict[str, "Model"]
]:
"""Create optimal model mappings based on cost and provider preferences.
This is the main entry point for the algorithm. It processes all upstream providers
and creates three mappings based on cost optimization:
1. model_instances: alias -> Model (all model aliases mapped to their Model objects)
2. provider_map: alias -> List[UpstreamProvider] (sorted list of providers for each alias)
3. unique_models: base_id -> Model (unique models without provider prefixes)
The algorithm:
- Processes non-OpenRouter providers first (they're typically cheaper)
- Then processes OpenRouter models (they can still win if cheaper)
- For each model alias, collects all candidates and sorts them by priority and cost.
Args:
upstreams: List of all upstream provider instances
overrides_by_id: Dict of model overrides from database {model_id: (ModelRow, fee)}
disabled_model_ids: Set of model IDs that should be excluded
Returns:
Tuple of (model_instances, provider_map, unique_models)
"""
from .payment.models import _row_to_model
from .upstream.helpers import resolve_model_alias
candidates: dict[str, list[tuple["Model", "BaseUpstreamProvider"]]] = {}
unique_models: dict[str, "Model"] = {}
# Separate OpenRouter from other providers
openrouter: "BaseUpstreamProvider" | None = None
other_upstreams: list["BaseUpstreamProvider"] = []
for upstream in upstreams:
base_url = getattr(upstream, "base_url", "")
if base_url == "https://openrouter.ai/api/v1":
openrouter = upstream
else:
other_upstreams.append(upstream)
def get_base_model_id(model_id: str) -> str:
"""Get base model ID by removing provider prefix."""
return model_id.split("/", 1)[1] if "/" in model_id else model_id
def _add_candidate(
alias: str, model: "Model", provider: "BaseUpstreamProvider"
) -> None:
"""Add candidate model/provider for an alias."""
alias_lower = alias.lower()
if alias_lower not in candidates:
candidates[alias_lower] = []
candidates[alias_lower].append((model, provider))
def process_provider_models(
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
) -> None:
"""Process all models from a given provider."""
upstream_prefix = getattr(upstream, "upstream_name", None)
for model in upstream.get_cached_models():
if not model.enabled or model.id in disabled_model_ids:
continue
# Apply overrides if present
if model.id in overrides_by_id:
override_row, provider_fee = overrides_by_id[model.id]
model_to_use = _row_to_model(
override_row, apply_provider_fee=True, provider_fee=provider_fee
)
else:
model_to_use = model
# Add to unique models
base_id = get_base_model_id(model_to_use.id)
if not is_openrouter or base_id not in unique_models:
unique_model = model_to_use.copy(
update={
"id": base_id,
"upstream_provider_id": upstream.provider_type,
}
)
unique_models[base_id] = unique_model
# Get all aliases for this model
aliases = resolve_model_alias(
model_to_use.id,
model_to_use.canonical_slug,
alias_ids=model_to_use.alias_ids,
)
# Add prefixed alias if applicable
if upstream_prefix and "/" not in model_to_use.id:
prefixed_id = f"{upstream_prefix}/{model_to_use.id}"
if prefixed_id not in aliases:
aliases.append(prefixed_id)
# Try to set each alias
for alias in aliases:
_add_candidate(alias, model_to_use, upstream)
# Process non-OpenRouter providers first
for upstream in other_upstreams:
process_provider_models(upstream, is_openrouter=False)
# Process OpenRouter last
if openrouter:
process_provider_models(openrouter, is_openrouter=True)
# Sort candidates and build final maps
model_instances: dict[str, "Model"] = {}
provider_map: dict[str, list["BaseUpstreamProvider"]] = {}
def alias_priority(model: "Model", alias: str) -> int:
"""Rank how strong the mapping of alias->model is."""
model_base = get_base_model_id(model.id)
if model_base == alias:
return 3
if model.canonical_slug:
canonical_base = get_base_model_id(model.canonical_slug)
if canonical_base == alias:
return 2
return 1
for alias, items in candidates.items():
# Sort key: (priority DESC, cost ASC)
# Using negative cost for DESC sort overall to keep high priority first
def sort_key(item: tuple["Model", "BaseUpstreamProvider"]) -> tuple[int, float]:
model, provider = item
priority = alias_priority(model, alias)
cost = calculate_model_cost_score(model)
penalty = get_provider_penalty(provider)
adjusted_cost = cost * penalty
return (priority, -adjusted_cost)
items.sort(key=sort_key, reverse=True)
best_model, best_provider = items[0]
model_instances[alias] = best_model
provider_map[alias] = [p for _, p in items]
# Log provider distribution (using top provider for stats)
provider_counts: dict[str, int] = {}
for providers in provider_map.values():
if providers:
provider = providers[0]
provider_name = getattr(provider, "upstream_name", "unknown")
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
logger.debug(
f"Updated model mappings with ({len(unique_models)} unique models and {len(model_instances)} aliases)",
extra={"provider_distribution": provider_counts},
)
return model_instances, provider_map, unique_models
-817
View File
@@ -1,817 +0,0 @@
import hashlib
import math
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlmodel import col, update
from .core import get_logger
from .core.db import ApiKey, AsyncSession
from .core.settings import settings
from .payment.cost_calculation import (
CostData,
CostDataError,
MaxCostData,
calculate_cost,
)
from .wallet import credit_balance, deserialize_token_from_string
logger = get_logger(__name__)
# TODO: implement prepaid api key (not like it was before)
# PREPAID_API_KEY = os.environ.get("PREPAID_API_KEY", None)
# PREPAID_BALANCE = int(os.environ.get("PREPAID_BALANCE", "0")) * 1000 # Convert to msats
async def validate_bearer_key(
bearer_key: str,
session: AsyncSession,
refund_address: Optional[str] = None,
key_expiry_time: Optional[int] = None,
) -> ApiKey:
"""
Validates the provided API key using SQLModel.
If it's a cashu key, it redeems it and stores its hash and balance.
Otherwise checks if the hash of the key exists.
"""
logger.debug(
"Starting bearer key validation",
extra={
"key_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
"has_refund_address": bool(refund_address),
"has_expiry_time": bool(key_expiry_time),
},
)
if not bearer_key:
logger.error("Empty bearer key provided")
raise HTTPException(
status_code=401,
detail={
"error": {
"message": "API key or Cashu token required",
"type": "invalid_request_error",
"code": "missing_api_key",
}
},
)
if bearer_key.startswith("sk-"):
logger.debug(
"Processing sk- prefixed API key",
extra={"key_preview": bearer_key[:10] + "..."},
)
if existing_key := await session.get(ApiKey, bearer_key[3:]):
logger.info(
"Existing sk- API key found",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"balance": existing_key.balance,
"total_requests": existing_key.total_requests,
},
)
if key_expiry_time is not None:
existing_key.key_expiry_time = key_expiry_time
logger.debug(
"Updated key expiry time",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"expiry_time": key_expiry_time,
},
)
if refund_address is not None:
existing_key.refund_address = refund_address
logger.debug(
"Updated refund address",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"refund_address_preview": refund_address[:20] + "..."
if len(refund_address) > 20
else refund_address,
},
)
return existing_key
else:
logger.warning(
"sk- API key not found in database",
extra={"key_preview": bearer_key[:10] + "..."},
)
if bearer_key.startswith("cashu"):
logger.debug(
"Processing Cashu token",
extra={
"token_preview": bearer_key[:20] + "...",
"token_type": bearer_key[:6] if len(bearer_key) >= 6 else bearer_key,
},
)
try:
hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest()
token_obj = deserialize_token_from_string(bearer_key)
logger.debug(
"Generated token hash", extra={"hash_preview": hashed_key[:16] + "..."}
)
if existing_key := await session.get(ApiKey, hashed_key):
logger.info(
"Existing Cashu token found",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"balance": existing_key.balance,
"total_requests": existing_key.total_requests,
},
)
if key_expiry_time is not None:
existing_key.key_expiry_time = key_expiry_time
logger.debug(
"Updated key expiry time for existing Cashu key",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"expiry_time": key_expiry_time,
},
)
if refund_address is not None:
existing_key.refund_address = refund_address
logger.debug(
"Updated refund address for existing Cashu key",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"refund_address_preview": refund_address[:20] + "..."
if len(refund_address) > 20
else refund_address,
},
)
return existing_key
logger.info(
"Creating new Cashu token entry",
extra={
"hash_preview": hashed_key[:16] + "...",
"has_refund_address": bool(refund_address),
"has_expiry_time": bool(key_expiry_time),
},
)
if token_obj.mint in settings.cashu_mints:
refund_currency = token_obj.unit
refund_mint_url = token_obj.mint
else:
refund_currency = "sat"
refund_mint_url = settings.primary_mint
new_key = ApiKey(
hashed_key=hashed_key,
balance=0,
refund_address=refund_address,
key_expiry_time=key_expiry_time,
refund_currency=refund_currency,
refund_mint_url=refund_mint_url,
)
session.add(new_key)
try:
await session.flush()
except IntegrityError:
await session.rollback()
logger.info(
"Concurrent key creation detected, fetching existing key",
extra={"key_hash": hashed_key[:8] + "..."},
)
existing_key = await session.get(ApiKey, hashed_key)
if not existing_key:
raise Exception("Failed to fetch existing key after IntegrityError")
if key_expiry_time is not None:
existing_key.key_expiry_time = key_expiry_time
if refund_address is not None:
existing_key.refund_address = refund_address
return existing_key
logger.debug(
"New key created, starting token redemption",
extra={"key_hash": hashed_key[:8] + "..."},
)
logger.info(
"AUTH: About to call credit_balance",
extra={"token_preview": bearer_key[:50]},
)
try:
msats = await credit_balance(bearer_key, new_key, session)
logger.info(
"AUTH: credit_balance returned successfully", extra={"msats": msats}
)
except Exception as credit_error:
logger.error(
"AUTH: credit_balance failed",
extra={
"error": str(credit_error),
"error_type": type(credit_error).__name__,
},
)
raise credit_error
if msats <= 0:
logger.error(
"Token redemption returned zero or negative amount",
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
)
raise Exception("Token redemption failed")
await session.refresh(new_key)
await session.commit()
logger.info(
"New Cashu token successfully redeemed and stored",
extra={
"key_hash": hashed_key[:8] + "...",
"redeemed_msats": msats,
"final_balance": new_key.balance,
},
)
return new_key
except Exception as e:
logger.error(
"Cashu token redemption failed",
extra={
"error": str(e),
"error_type": type(e).__name__,
"token_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
},
)
raise HTTPException(
status_code=401,
detail={
"error": {
"message": f"Invalid or expired Cashu key: {str(e)}",
"type": "invalid_request_error",
"code": "invalid_api_key",
}
},
)
logger.error(
"Invalid API key format",
extra={
"key_preview": bearer_key[:10] + "..."
if len(bearer_key) > 10
else bearer_key,
"key_length": len(bearer_key),
},
)
raise HTTPException(
status_code=401,
detail={
"error": {
"message": "Invalid API key",
"type": "invalid_request_error",
"code": "invalid_api_key",
}
},
)
async def get_billing_key(key: ApiKey, session: AsyncSession) -> ApiKey:
"""Returns the key that should be charged for the request."""
if key.parent_key_hash:
parent = await session.get(ApiKey, key.parent_key_hash)
if parent:
# We want to keep the total_requests and total_spent on the child key
# but use the balance and reserved_balance of the parent.
# However, pay_for_request updates reserved_balance and total_requests.
# To stay simple, we charge the parent's balance and update parent's total_requests.
return parent
else:
logger.error(
"Parent key not found for child key",
extra={
"child_key_hash": key.hashed_key[:8] + "...",
"parent_key_hash": key.parent_key_hash[:8] + "...",
},
)
return key
async def pay_for_request(
key: ApiKey, cost_per_request: int, session: AsyncSession
) -> int:
"""Process payment for a request."""
billing_key = await get_billing_key(key, session)
logger.info(
"Processing payment for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"current_balance": billing_key.balance,
"required_cost": cost_per_request,
"sufficient_balance": billing_key.balance >= cost_per_request,
},
)
if billing_key.total_balance < cost_per_request:
logger.warning(
"Insufficient balance for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"balance": billing_key.balance,
"reserved_balance": billing_key.reserved_balance,
"required": cost_per_request,
"shortfall": cost_per_request - billing_key.total_balance,
},
)
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Insufficient balance: {cost_per_request} mSats required. {billing_key.total_balance} available. (reserved: {billing_key.reserved_balance})",
"type": "insufficient_quota",
"code": "insufficient_balance",
}
},
)
logger.debug(
"Charging base cost for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"cost": cost_per_request,
"balance_before": billing_key.balance,
},
)
# Charge the base cost for the request atomically to avoid race conditions
stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= cost_per_request)
.values(
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
total_requests=col(ApiKey.total_requests) + 1,
)
)
result = await session.exec(stmt) # type: ignore[call-overload]
# Also increment total_requests on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(total_requests=col(ApiKey.total_requests) + 1)
)
await session.exec(child_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Concurrent request depleted balance",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"required_cost": cost_per_request,
"current_balance": billing_key.balance,
},
)
# Another concurrent request spent the balance first
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Insufficient balance: {cost_per_request} mSats required. {billing_key.balance} available.",
"type": "insufficient_quota",
"code": "insufficient_balance",
}
},
)
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
logger.info(
"Payment processed successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"charged_amount": cost_per_request,
"new_balance": billing_key.balance,
"total_spent": billing_key.total_spent,
"total_requests": billing_key.total_requests,
},
)
return cost_per_request
async def revert_pay_for_request(
key: ApiKey, session: AsyncSession, cost_per_request: int
) -> None:
billing_key = await get_billing_key(key, session)
stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
total_requests=col(ApiKey.total_requests) - 1,
)
)
result = await session.exec(stmt) # type: ignore[call-overload]
# Also decrement total_requests on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(total_requests=col(ApiKey.total_requests) - 1)
)
await session.exec(child_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] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"cost_to_revert": cost_per_request,
"current_reserved_balance": billing_key.reserved_balance,
},
)
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"failed to revert request payment: {cost_per_request} mSats required. {billing_key.balance} available.",
"type": "payment_error",
"code": "payment_error",
}
},
)
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
async def adjust_payment_for_tokens(
key: ApiKey, response_data: dict, session: AsyncSession, deducted_max_cost: int
) -> dict:
"""
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.
"""
billing_key = await get_billing_key(key, session)
model = response_data.get("model", "unknown")
logger.debug(
"Starting payment adjustment for tokens",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"model": model,
"deducted_max_cost": deducted_max_cost,
"current_balance": billing_key.balance,
"has_usage": "usage" in response_data,
},
)
async def release_reservation_only() -> None:
"""Fallback to release reservation without charging when main update fails."""
try:
release_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost
)
)
await session.exec(release_stmt) # type: ignore[call-overload]
await session.commit()
logger.warning(
"Released reservation without charging (fallback)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
},
)
except Exception as e:
logger.error(
"Failed to release reservation in fallback",
extra={
"error": str(e),
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
},
)
match await calculate_cost(response_data, deducted_max_cost, session):
case MaxCostData() as cost:
logger.debug(
"Using max cost data (no token adjustment)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"model": model,
"max_cost": cost.total_msats,
},
)
# Finalize by releasing reservation and charging max cost
finalize_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
balance=col(ApiKey.balance) - cost.total_msats,
total_spent=col(ApiKey.total_spent) + cost.total_msats,
)
)
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
# Also update total_spent on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(total_spent=col(ApiKey.total_spent) + cost.total_msats)
)
await session.exec(child_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Failed to finalize max-cost payment - retrying reservation release",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
"current_reserved_balance": billing_key.reserved_balance,
"total_cost": cost.total_msats,
"model": model,
},
)
await release_reservation_only()
else:
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
logger.info(
"Max cost payment finalized",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"charged_amount": cost.total_msats,
"new_balance": billing_key.balance,
"model": model,
},
)
return cost.dict()
case CostData() as cost:
# 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",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"model": model,
"token_cost": cost.total_msats,
"deducted_max_cost": deducted_max_cost,
"cost_difference": cost_difference,
"input_msats": cost.input_msats,
"output_msats": cost.output_msats,
},
)
if cost_difference == 0:
logger.debug(
"Finalizing with exact reserved cost",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"model": model,
},
)
finalize_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
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(finalize_stmt) # type: ignore[call-overload]
# Also update total_spent on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
)
await session.exec(child_stmt) # type: ignore[call-overload]
await session.commit()
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
return cost.dict()
# this should never happen why do we handle this???
if cost_difference > 0:
# Need to charge more than reserved, finalize by releasing reservation and charging total
logger.info(
"Additional charge required for token usage",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"additional_charge": cost_difference,
"current_balance": billing_key.balance,
"sufficient_balance": billing_key.balance >= cost_difference,
"model": model,
},
)
finalize_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
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,
)
)
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
# Also update total_spent on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
)
await session.exec(child_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount:
cost.total_msats = total_cost_msats
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
logger.info(
"Finalized payment with additional charge",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"charged_amount": total_cost_msats,
"new_balance": billing_key.balance,
"model": model,
},
)
else:
logger.warning(
"Failed to finalize additional charge - releasing reservation",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"attempted_charge": total_cost_msats,
"model": model,
},
)
await release_reservation_only()
else:
# Refund some of the base cost
refund = abs(cost_difference)
logger.info(
"Refunding excess payment",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"refund_amount": refund,
"current_balance": billing_key.balance,
"model": model,
},
)
refund_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
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,
)
)
result = await session.exec(refund_stmt) # type: ignore[call-overload]
# Also update total_spent on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
)
await session.exec(child_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Failed to finalize payment - releasing reservation",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
"current_reserved_balance": billing_key.reserved_balance,
"total_cost": total_cost_msats,
"model": model,
},
)
await release_reservation_only()
else:
cost.total_msats = total_cost_msats
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
logger.info(
"Refund processed successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"refunded_amount": refund,
"new_balance": billing_key.balance,
"final_cost": cost.total_msats,
"model": model,
},
)
return cost.dict()
case CostDataError() as error:
logger.error(
"Cost calculation error during payment adjustment - releasing reservation",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": model,
"error_message": error.message,
"error_code": error.code,
},
)
await release_reservation_only()
raise HTTPException(
status_code=400,
detail={
"error": {
"message": error.message,
"type": "invalid_request_error",
"code": error.code,
}
},
)
# Fallback: should not reach here, but release reservation just in case
logger.error(
"Unexpected fallback in adjust_payment_for_tokens - releasing reservation",
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
)
await release_reservation_only()
return {
"base_msats": deducted_max_cost,
"input_msats": 0,
"output_msats": 0,
"total_msats": deducted_max_cost,
}
-339
View File
@@ -1,339 +0,0 @@
import asyncio
import hashlib
from time import monotonic
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
from .core.logging import get_logger
from .core.settings import settings
from .lightning import lightning_router
from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token
router = APIRouter()
balance_router = APIRouter(prefix="/v1/balance")
logger = get_logger(__name__)
async def get_key_from_header(
authorization: Annotated[str, Header(...)],
session: AsyncSession = Depends(get_session),
) -> ApiKey:
if authorization.startswith("Bearer "):
return await validate_bearer_key(authorization[7:], session)
raise HTTPException(
status_code=401,
detail="Invalid authorization. Use 'Bearer <cashu-token>' or 'Bearer <api-key>'",
)
async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
from .auth import get_billing_key
billing_key = await get_billing_key(key, session)
return {
"api_key": "sk-" + key.hashed_key,
"balance": billing_key.balance,
"reserved": billing_key.reserved_balance,
"is_child": key.parent_key_hash is not None,
"parent_key": "sk-" + key.parent_key_hash if key.parent_key_hash else None,
"total_requests": key.total_requests,
"total_spent": key.total_spent,
}
# TODO: remove this endpoint when frontend is updated
@router.get("/", include_in_schema=False)
async def account_info(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict:
return await get_balance_info(key, session)
# 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),
session: AsyncSession = Depends(get_session),
) -> dict:
return await get_balance_info(key, session)
class TopupRequest(BaseModel):
cashu_token: str
@router.post("/topup")
async def topup_wallet_endpoint(
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]:
from .auth import get_billing_key
billing_key = await get_billing_key(key, session)
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")
try:
amount_msats = await credit_balance(cashu_token, billing_key, session)
except ValueError as e:
error_msg = str(e)
if "already spent" in error_msg.lower():
raise HTTPException(status_code=400, detail="Token already spent")
elif "invalid" in error_msg.lower() or "decode" in error_msg.lower():
raise HTTPException(status_code=400, detail="Invalid token format")
else:
raise HTTPException(status_code=400, detail="Failed to redeem token")
except Exception:
raise HTTPException(status_code=500, detail="Internal server error")
return {"msats": amount_msats}
_REFUND_CACHE_TTL_SECONDS: int = settings.refund_cache_ttl_seconds
_refund_cache_lock: asyncio.Lock = asyncio.Lock()
_refund_cache: dict[str, tuple[float, dict[str, str]]] = {}
def _cache_key_for_authorization(authorization: str) -> str:
return hashlib.sha256(authorization.strip().encode()).hexdigest()
async def _refund_cache_get(authorization: str) -> dict[str, str] | None:
key = _cache_key_for_authorization(authorization)
async with _refund_cache_lock:
item = _refund_cache.get(key)
if item is None:
return None
expires_at, value = item
if expires_at <= monotonic():
del _refund_cache[key]
return None
return value
async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None:
key = _cache_key_for_authorization(authorization)
expiry = monotonic() + _REFUND_CACHE_TTL_SECONDS
async with _refund_cache_lock:
_refund_cache[key] = (expiry, value)
@router.post("/refund")
async def refund_wallet_endpoint(
authorization: Annotated[str, Header(...)],
session: AsyncSession = Depends(get_session),
) -> dict[str, str]:
if not authorization.startswith("Bearer "):
raise HTTPException(
status_code=401,
detail="Invalid authorization. Use 'Bearer <cashu-token>' or 'Bearer <api-key>'",
)
bearer_value: str = authorization[7:]
if cached := await _refund_cache_get(bearer_value):
return cached
key: ApiKey = await validate_bearer_key(bearer_value, session)
if key.parent_key_hash:
raise HTTPException(
status_code=400,
detail="Cannot refund child key. Please refund the parent key instead.",
)
remaining_balance_msats: int = key.total_balance
if key.refund_currency == "sat":
remaining_balance = remaining_balance_msats // 1000
else:
remaining_balance = remaining_balance_msats
if remaining_balance_msats > 0 and remaining_balance <= 0:
raise HTTPException(status_code=400, detail="Balance too small to refund")
elif remaining_balance <= 0:
raise HTTPException(status_code=400, detail="No balance to refund")
# Perform refund operation first, before modifying balance
try:
if key.refund_address:
from .core.settings import settings as global_settings
await send_to_lnurl(
remaining_balance,
key.refund_currency or "sat",
key.refund_mint_url or global_settings.primary_mint,
key.refund_address,
)
result = {"recipient": key.refund_address}
else:
refund_currency = key.refund_currency or "sat"
token = await send_token(
remaining_balance, refund_currency, key.refund_mint_url
)
result = {"token": token}
if key.refund_currency == "sat":
result["sats"] = str(remaining_balance_msats // 1000)
else:
result["msats"] = str(remaining_balance_msats)
except HTTPException:
# Re-raise HTTP exceptions (like 400 for balance too small)
raise
except Exception as e:
# If refund fails, don't modify the database
error_msg = str(e)
if (
"mint" in error_msg.lower()
or "connection" in error_msg.lower()
or isinstance(e, Exception)
and "ConnectError" in str(type(e))
):
raise HTTPException(status_code=503, detail="Mint service unavailable")
else:
raise HTTPException(status_code=500, detail="Refund failed")
await _refund_cache_set(bearer_value, result)
await session.delete(key)
await session.commit()
return result
@router.post("/donate")
async def donate(token: str, ref: str | None = None) -> str:
try:
amount, unit, _ = await recieve_token(token)
if ref:
logger.info(
"donation received", extra={"ref": ref, "amount": amount, "unit": unit}
)
return "Thanks!"
except Exception:
return "Invalid token."
class ChildKeyRequest(BaseModel):
count: int
@router.post("/child-key")
async def create_child_key(
payload: ChildKeyRequest,
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict:
"""Creates one or more child API keys that use the parent's balance."""
# Log incoming request for debugging
logger.debug(f"Child key creation request: count={payload.count}")
count = payload.count
if count < 1 or count > 50:
raise HTTPException(status_code=400, detail="Count must be between 1 and 50.")
# Check if this is already a child key
if key.parent_key_hash:
raise HTTPException(
status_code=400,
detail="Cannot create a child key for another child key.",
)
cost_per_key = settings.child_key_cost
total_cost = cost_per_key * count
if key.total_balance < total_cost:
raise HTTPException(
status_code=402,
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
)
# Deduct cost from parent
key.balance -= total_cost
key.total_spent += total_cost
session.add(key)
# Generate new keys
import secrets
new_keys = []
for _ in range(count):
new_key_raw = secrets.token_hex(32)
new_key_hash = new_key_raw # We use the raw key as the hash for sk- keys
child_key = ApiKey(
hashed_key=new_key_hash,
balance=0,
parent_key_hash=key.hashed_key,
)
session.add(child_key)
new_keys.append("sk-" + new_key_hash)
await session.commit()
response_data = {
"api_keys": new_keys,
"count": count,
"cost_msats": total_cost,
"cost_sats": total_cost // 1000,
"parent_balance": key.balance,
"parent_balance_sats": key.balance // 1000,
}
logger.debug(f"Child key creation response: {response_data}")
return response_data
@router.api_route(
"/{path:path}",
methods=["GET", "POST", "PUT", "DELETE"],
include_in_schema=False,
response_model=None,
)
async def wallet_catch_all(path: str) -> NoReturn:
raise HTTPException(
status_code=404, detail="Not found check /docs for available endpoints"
)
balance_router.include_router(lightning_router)
balance_router.include_router(router)
deprecated_wallet_router = APIRouter(prefix="/v1/wallet", include_in_schema=False)
deprecated_wallet_router.include_router(router)
File diff suppressed because it is too large Load Diff
-196
View File
@@ -1,196 +0,0 @@
import os
import time
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from alembic import command
from alembic.config import Config
from sqlalchemy import UniqueConstraint
from sqlalchemy.ext.asyncio.engine import create_async_engine
from sqlmodel import Field, Relationship, SQLModel, func, select, update
from sqlmodel.ext.asyncio.session import AsyncSession
from .logging import get_logger
logger = get_logger(__name__)
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///keys.db")
engine = create_async_engine(DATABASE_URL, echo=False) # echo=True for debugging SQL
class ApiKey(SQLModel, table=True): # type: ignore
__tablename__ = "api_keys"
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",
)
key_expiry_time: int | None = Field(
default=None,
description="Unix-timestamp after which the cashu-token's balance gets refunded to the refund_address",
)
total_spent: int = Field(
default=0, description="Total spent in millisatoshis (msats)"
)
total_requests: int = Field(default=0)
refund_mint_url: str | None = Field(
default=None,
description="URL of the mint used to create the cashu-token",
)
refund_currency: str | None = Field(
default=None,
description="Currency of the cashu-token",
)
parent_key_hash: str | None = Field(
default=None, foreign_key="api_keys.hashed_key", index=True
)
@property
def total_balance(self) -> int:
return self.balance - self.reserved_balance
async def reset_all_reserved_balances(session: AsyncSession) -> None:
logger.info("Resetting all reserved balances to 0")
stmt = update(ApiKey).values(reserved_balance=0)
await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
logger.info("Reserved balances reset successfully")
class ModelRow(SQLModel, table=True): # type: ignore
__tablename__ = "models"
id: str = Field(primary_key=True)
upstream_provider_id: int = Field(
primary_key=True, foreign_key="upstream_providers.id", ondelete="CASCADE"
)
name: str = Field()
created: int = Field()
description: str = Field()
context_length: int = Field()
architecture: str = Field()
pricing: str = Field()
sats_pricing: str | None = Field(default=None)
per_request_limits: str | None = Field(default=None)
top_provider: str | None = Field(default=None)
canonical_slug: str | None = Field(default=None, description="Canonical model slug")
alias_ids: str | None = Field(
default=None, description="JSON array of model alias IDs"
)
enabled: bool = Field(default=True, description="Whether this model is enabled")
upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models")
class LightningInvoice(SQLModel, table=True): # type: ignore
__tablename__ = "lightning_invoices"
id: str = Field(primary_key=True, description="Unique invoice identifier")
bolt11: str = Field(description="BOLT11 invoice string", unique=True)
amount_sats: int = Field(description="Amount in satoshis")
description: str = Field(description="Invoice description")
payment_hash: str = Field(description="Payment hash for tracking", unique=True)
status: str = Field(
default="pending", description="pending, paid, expired, cancelled"
)
api_key_hash: str | None = Field(
default=None, description="Associated API key hash for topup operations"
)
purpose: str = Field(description="create or topup")
created_at: int = Field(
default_factory=lambda: int(time.time()), description="Unix timestamp"
)
expires_at: int = Field(description="Unix timestamp when invoice expires")
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
__tablename__ = "upstream_providers"
__table_args__ = (
UniqueConstraint("base_url", "api_key", name="uq_upstream_providers_base_url_api_key"),
)
id: int | None = Field(default=None, primary_key=True)
provider_type: str = Field(
description="Provider type: custom, openai, anthropic, azure, openrouter, etc."
)
base_url: str = Field(description="Base URL of the upstream API")
api_key: str = Field(description="API key for the upstream provider")
api_version: str | None = Field(
default=None, description="API version for Azure OpenAI"
)
enabled: bool = Field(default=True, description="Whether this provider is enabled")
provider_fee: float = Field(
default=1.01, description="Provider fee multiplier (default 1%)"
)
models: list["ModelRow"] = Relationship(
back_populates="upstream_provider",
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
)
async def balances_for_mint_and_unit(
db_session: AsyncSession, mint_url: str, unit: str
) -> int:
query = select(func.sum(ApiKey.balance)).where(
ApiKey.refund_mint_url == mint_url, ApiKey.refund_currency == unit
)
result = await db_session.exec(query)
return result.one() or 0
async def init_db() -> None:
"""Initializes the database and creates tables if they don't exist."""
async with engine.begin() as conn:
if DATABASE_URL.startswith("sqlite"):
await conn.exec_driver_sql("PRAGMA journal_mode=WAL")
await conn.run_sync(SQLModel.metadata.create_all)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSession(engine, expire_on_commit=False) as session:
yield session
@asynccontextmanager
async def create_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSession(engine, expire_on_commit=False) as session:
yield session
def run_migrations() -> None:
"""Run Alembic migrations programmatically."""
import pathlib
try:
# Get the path to the alembic.ini file
project_root = pathlib.Path(__file__).resolve().parents[2]
alembic_ini_path = project_root / "alembic.ini"
if not alembic_ini_path.exists():
raise FileNotFoundError(
f"Alembic configuration file not found at {alembic_ini_path}"
)
# Create Alembic config object
alembic_cfg = Config(str(alembic_ini_path))
# Set the database URL in the config
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
# Run migrations to the latest revision
command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully")
except Exception as e:
logger.error(
"Database migration failed",
extra={"error": str(e), "error_type": type(e).__name__},
)
raise
-66
View File
@@ -1,66 +0,0 @@
from fastapi import Request
from fastapi.responses import JSONResponse
from .logging import get_logger
logger = get_logger(__name__)
class UpstreamError(Exception):
"""Exception raised when an upstream provider fails."""
def __init__(self, message: str, status_code: int = 502):
self.message = message
self.status_code = status_code
super().__init__(message)
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handle HTTP exceptions and include request ID in response."""
request_id = getattr(request.state, "request_id", "unknown")
# Get status code and detail - works for both FastAPI and Starlette HTTPException
status_code = getattr(exc, "status_code", 500)
detail = getattr(exc, "detail", str(exc))
logger.warning(
"HTTP exception",
extra={
"request_id": request_id,
"status_code": status_code,
"detail": detail,
"path": request.url.path,
},
)
return JSONResponse(
status_code=status_code,
content={
"detail": detail,
"request_id": request_id,
},
)
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handle general exceptions and include request ID in response."""
request_id = getattr(request.state, "request_id", "unknown")
logger.error(
"Unhandled exception",
extra={
"request_id": request_id,
"error": str(exc),
"error_type": type(exc).__name__,
"path": request.url.path,
},
exc_info=True,
)
return JSONResponse(
status_code=500,
content={
"detail": "Internal server error, please contact support with the request ID.",
"request_id": request_id,
},
)
-511
View File
@@ -1,511 +0,0 @@
import json
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterator
from .logging import get_logger
logger = get_logger(__name__)
class LogManager:
def __init__(self, logs_dir: Path = Path("logs")):
self.logs_dir = logs_dir
def _yield_log_entries(
self,
hours_back: int | None = None,
specific_date: str | None = None,
reverse_files: bool = False,
max_files: int | None = None,
) -> Iterator[dict[str, Any]]:
"""
Yields log entries from files.
Args:
hours_back: specific number of hours to look back.
specific_date: specific date string (YYYY-MM-DD) to look at.
reverse_files: if True, process files in reverse order (newest first).
max_files: maximum number of log files to process (most recent if reverse_files is True).
"""
if not self.logs_dir.exists():
return
log_files = []
cutoff_date = None
if specific_date:
log_file = self.logs_dir / f"app_{specific_date}.log"
if log_file.exists():
log_files.append(log_file)
else:
log_files = sorted(self.logs_dir.glob("app_*.log"))
if reverse_files:
log_files.reverse()
# If we only care about hours back, we can optimize file selection
if hours_back is not None:
cutoff_date = datetime.now(timezone.utc) - timedelta(hours=hours_back)
filtered_files = []
for log_path in log_files:
try:
file_date_str = log_path.stem.split("_")[1]
file_date = datetime.strptime(
file_date_str, "%Y-%m-%d"
).replace(tzinfo=timezone.utc)
# Include file if it's from the same day or after the cutoff day
if file_date >= cutoff_date.replace(
hour=0, minute=0, second=0, microsecond=0
):
filtered_files.append(log_path)
except Exception:
continue
log_files = filtered_files
if max_files is not None and len(log_files) > max_files:
log_files = log_files[:max_files]
for log_file in log_files:
try:
with open(log_file, "r") as f:
# For reverse search, we might want to read lines in reverse?
# But usually logs are append-only.
# If reverse_files is True, we iterate files newest to oldest.
# But lines within file are still oldest to newest unless we reverse them.
lines = f.readlines()
if reverse_files:
lines.reverse()
for line in lines:
try:
entry = json.loads(line.strip())
if cutoff_date:
timestamp_str = entry.get("asctime", "")
if not timestamp_str:
continue
log_time = datetime.strptime(
timestamp_str, "%Y-%m-%d %H:%M:%S"
)
log_time = log_time.replace(tzinfo=timezone.utc)
if log_time < cutoff_date:
continue
yield entry
except json.JSONDecodeError:
continue
except Exception as e:
logger.error(f"Error processing log file {log_file}: {e}")
continue
def search_logs(
self,
date: str | None = None,
level: str | None = None,
request_id: str | None = None,
search_text: str | None = None,
status_codes: list[int] | None = None,
methods: list[str] | None = None,
endpoints: list[str] | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
"""
Search through log files and return matching entries.
"""
log_entries: list[dict[str, Any]] = []
# Use reverse=True to get newest logs first by default
# If date is specified, we only look at that file
search_text_lower = search_text.lower() if search_text else None
# We iterate efficiently
iterator = self._yield_log_entries(
specific_date=date,
reverse_files=True if not date else False,
max_files=7 if not date else None,
)
# If we are searching globally (no date), we might want to limit how far back we go?
# PR 228 did: "glob("app_*.log") sorted by mtime reverse [:7]" (last 7 files)
# My _yield_log_entries with reverse_files=True does all files.
# Let's rely on limit to stop us.
# Optimization: if we are not searching by date, maybe limit to last 7 files inside _yield?
# For now, let's just iterate.
for log_data in iterator:
if not self._matches_filters(
log_data,
level,
request_id,
search_text_lower,
status_codes,
methods,
endpoints,
):
continue
log_entries.append(log_data)
if len(log_entries) >= limit:
break
# Sort by time descending (newest first)
log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=True)
return log_entries
def _matches_filters(
self,
log_data: dict[str, Any],
level: str | None,
request_id: str | None,
search_text_lower: str | None,
status_codes: list[int] | None = None,
methods: list[str] | None = None,
endpoints: list[str] | None = None,
) -> bool:
if level and log_data.get("levelname", "").upper() != level.upper():
return False
if request_id and log_data.get("request_id") != request_id:
return False
if status_codes:
entry_status = log_data.get("status_code")
if entry_status is not None:
try:
if int(entry_status) not in status_codes:
return False
except (ValueError, TypeError):
return False
else:
return False
if methods:
entry_method = log_data.get("method", "").upper()
if entry_method not in [m.upper() for m in methods]:
return False
if endpoints:
entry_path = log_data.get("path", "")
matched = False
for endpoint in endpoints:
clean_endpoint = endpoint.lstrip("/")
if entry_path.startswith(clean_endpoint):
matched = True
break
if clean_endpoint in entry_path:
matched = True
break
if not matched:
return False
if search_text_lower:
message = str(log_data.get("message", "")).lower()
name = str(log_data.get("name", "")).lower()
pathname = str(log_data.get("pathname", "")).lower()
if (
search_text_lower not in message
and search_text_lower not in name
and search_text_lower not in pathname
):
return False
return True
def get_usage_summary(self, hours: int = 24) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
return self._calculate_summary_stats(entries)
def get_usage_metrics(self, interval: int = 15, hours: int = 24) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
return self._aggregate_metrics_by_time(entries, interval, hours)
def get_error_details(self, hours: int = 24, limit: int = 100) -> dict:
errors: list[dict] = []
# Iterate newest to oldest for errors?
# yield_log_entries sorts files by name (date) ascending by default.
# usage stats logic usually expects ascending time for aggregation (though dictionaries don't care).
# For error details "last N errors", we probably want newest first.
# Using list() loads everything into memory, which is what PR 229 did.
# For optimization, we could use reverse iterator.
# Let's just stick to PR 229 logic which filters 'ERROR' level.
entries = self._yield_log_entries(hours_back=hours) # oldest to newest
for entry in entries:
if entry.get("levelname", "").upper() == "ERROR":
timestamp_str = entry.get("asctime", "")
errors.append(
{
"timestamp": timestamp_str,
"message": entry.get("message", ""),
"error_type": entry.get("error_type", "unknown"),
"pathname": entry.get("pathname", ""),
"lineno": entry.get("lineno", 0),
"request_id": entry.get("request_id", ""),
}
)
# Sort reverse time
errors.sort(key=lambda x: x["timestamp"], reverse=True)
return {"errors": errors[:limit], "total_count": len(errors)}
def get_revenue_by_model(self, hours: int = 24, limit: int = 20) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
model_stats: dict[str, dict[str, int | float]] = defaultdict(
lambda: {
"revenue_msats": 0,
"refunds_msats": 0,
"requests": 0,
"successful": 0,
"failed": 0,
}
)
for entry in entries:
try:
model = entry.get("model", "unknown")
if not isinstance(model, str):
model = "unknown"
message = entry.get("message", "").lower()
if "received proxy request" in message:
model_stats[model]["requests"] += 1
if (
"completed for streaming" in message
or "completed for non-streaming" in message
):
model_stats[model]["successful"] += 1
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
actual_cost = cost_data.get("total_msats", 0)
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
model_stats[model]["revenue_msats"] += actual_cost
if "revert payment" in message or "upstream request failed" in message:
model_stats[model]["failed"] += 1
if "revert payment" in message:
max_cost = entry.get("max_cost_for_model", 0)
if isinstance(max_cost, (int, float)) and max_cost > 0:
model_stats[model]["refunds_msats"] += max_cost
except Exception:
continue
models: list[dict[str, Any]] = []
total_revenue = 0.0
for model, stats in model_stats.items():
revenue_msats = float(stats["revenue_msats"])
refunds_msats = float(stats["refunds_msats"])
revenue_sats = revenue_msats / 1000
refunds_sats = refunds_msats / 1000
net_revenue_sats = revenue_sats - refunds_sats
total_revenue += net_revenue_sats
requests = int(stats["requests"])
successful = int(stats["successful"])
models.append(
{
"model": model,
"revenue_sats": revenue_sats,
"refunds_sats": refunds_sats,
"net_revenue_sats": net_revenue_sats,
"requests": requests,
"successful": successful,
"failed": int(stats["failed"]),
"avg_revenue_per_request": (
revenue_sats / successful if successful > 0 else 0
),
}
)
models.sort(key=lambda x: float(x["net_revenue_sats"]), reverse=True)
return {
"models": models[:limit],
"total_revenue_sats": total_revenue,
"total_models": len(models),
}
def _calculate_summary_stats(self, entries: list[dict]) -> dict:
stats: dict[str, Any] = {
"total_entries": 0,
"total_requests": 0,
"successful_chat_completions": 0,
"failed_requests": 0,
"total_errors": 0,
"total_warnings": 0,
"payment_processed": 0,
"upstream_errors": 0,
"unique_models": set(),
"error_types": defaultdict(int),
"revenue_msats": 0.0,
"refunds_msats": 0.0,
}
for entry in entries:
try:
stats["total_entries"] += 1
message = entry.get("message", "").lower()
level = entry.get("levelname", "").upper()
if level == "ERROR":
stats["total_errors"] += 1
if "error_type" in entry:
stats["error_types"][str(entry["error_type"])] += 1
elif level == "WARNING":
stats["total_warnings"] += 1
if "received proxy request" in message:
stats["total_requests"] += 1
if (
"completed for streaming" in message
or "completed for non-streaming" in message
):
stats["successful_chat_completions"] += 1
if "upstream request failed" in message or "revert payment" in message:
stats["failed_requests"] += 1
if "payment processed successfully" in message:
stats["payment_processed"] += 1
if "upstream" in message and level == "ERROR":
stats["upstream_errors"] += 1
if "model" in entry:
model = entry["model"]
if isinstance(model, str) and model != "unknown":
stats["unique_models"].add(model)
if (
"completed for streaming" in message
or "completed for non-streaming" in message
):
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
actual_cost = cost_data.get("total_msats", 0)
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
stats["revenue_msats"] += float(actual_cost)
if "revert payment" in message:
max_cost = entry.get("max_cost_for_model", 0)
if isinstance(max_cost, (int, float)) and max_cost > 0:
stats["refunds_msats"] += float(max_cost)
except Exception:
continue
revenue_sats = stats["revenue_msats"] / 1000
refunds_sats = stats["refunds_msats"] / 1000
net_revenue_sats = revenue_sats - refunds_sats
total_requests = stats["total_requests"]
successful = stats["successful_chat_completions"]
return {
"total_entries": stats["total_entries"],
"total_requests": total_requests,
"successful_chat_completions": successful,
"failed_requests": stats["failed_requests"],
"total_errors": stats["total_errors"],
"total_warnings": stats["total_warnings"],
"payment_processed": stats["payment_processed"],
"upstream_errors": stats["upstream_errors"],
"unique_models_count": len(stats["unique_models"]),
"unique_models": sorted(list(stats["unique_models"])),
"error_types": dict(stats["error_types"]),
"success_rate": (successful / total_requests * 100)
if total_requests > 0
else 0,
"revenue_msats": stats["revenue_msats"],
"refunds_msats": stats["refunds_msats"],
"revenue_sats": revenue_sats,
"refunds_sats": refunds_sats,
"net_revenue_msats": stats["revenue_msats"] - stats["refunds_msats"],
"net_revenue_sats": net_revenue_sats,
"avg_revenue_per_request_msats": (
stats["revenue_msats"] / successful if successful > 0 else 0
),
"refund_rate": (
(stats["failed_requests"] / total_requests * 100)
if total_requests > 0
else 0
),
}
def _aggregate_metrics_by_time(
self, entries: list[dict], interval_minutes: int, hours_back: int
) -> dict:
time_buckets: dict[str, dict[str, Any]] = defaultdict(
lambda: {"requests": 0, "errors": 0, "revenue_msats": 0.0}
)
for entry in entries:
try:
timestamp_str = entry.get("asctime", "")
if not timestamp_str:
continue
log_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
log_time = log_time.replace(tzinfo=timezone.utc)
# Round down to nearest interval
minutes = log_time.minute
rounded_minutes = (minutes // interval_minutes) * interval_minutes
bucket_time = log_time.replace(
minute=rounded_minutes, second=0, microsecond=0
)
bucket_key = bucket_time.strftime("%Y-%m-%d %H:%M:%S")
bucket = time_buckets[bucket_key]
message = entry.get("message", "").lower()
level = entry.get("levelname", "").upper()
if "received proxy request" in message:
bucket["requests"] += 1
if level == "ERROR":
bucket["errors"] += 1
if (
"completed for streaming" in message
or "completed for non-streaming" in message
):
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
actual_cost = cost_data.get("total_msats", 0)
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
bucket["revenue_msats"] += float(actual_cost)
except Exception:
continue
result = []
for bucket_key in sorted(time_buckets.keys()):
result.append({"timestamp": bucket_key, **time_buckets[bucket_key]})
return {
"metrics": result,
"interval_minutes": interval_minutes,
"hours_back": hours_back,
"total_buckets": len(result),
}
log_manager = LogManager()
-344
View File
@@ -1,344 +0,0 @@
import asyncio
import os
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException
from ..balance import balance_router, deprecated_wallet_router
from ..discovery import providers_cache_refresher, providers_router
from ..nip91 import announce_provider
from ..payment.models import models_router, update_sats_pricing
from ..payment.price import update_prices_periodically
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
from ..wallet import periodic_payout
from .admin import admin_router
from .db import create_session, init_db, run_migrations
from .exceptions import general_exception_handler, http_exception_handler
from .logging import get_logger, setup_logging
from .middleware import LoggingMiddleware
from .settings import SettingsService
from .settings import settings as global_settings
# Initialize logging first
setup_logging()
logger = get_logger(__name__)
if os.getenv("VERSION_SUFFIX") is not None:
__version__ = f"0.3.0-{os.getenv('VERSION_SUFFIX')}"
else:
__version__ = "0.3.0"
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
logger.info("Application startup initiated", extra={"version": __version__})
btc_price_task = None
pricing_task = None
payout_task = None
nip91_task = None
providers_task = None
models_refresh_task = None
model_maps_refresh_task = None
try:
# Run database migrations on startup
run_migrations()
# Initialize database connection pools
# This creates any tables that might not be tracked by migrations yet
await init_db()
# Initialize application settings (env -> computed -> DB precedence)
async with create_session() as session:
s = await SettingsService.initialize(session)
if s.reset_reserved_balance_on_startup:
from .db import reset_all_reserved_balances
await reset_all_reserved_balances(session)
if not s.admin_password:
logger.warning(
f"Admin password is not set. Visit {s.http_url or 'http://localhost:8000'}/admin to set the password."
)
# Apply app metadata from settings
try:
app.title = s.name
app.description = s.description
except Exception:
pass
# await ensure_models_bootstrapped()
from ..payment.price import _update_prices
from ..proxy import get_upstreams
from ..upstream.helpers import refresh_upstreams_models_periodically
_update_prices_task = asyncio.create_task(_update_prices())
_initialize_upstreams_task = asyncio.create_task(initialize_upstreams())
# ensure both setup tasks complete
await asyncio.gather(
_update_prices_task, _initialize_upstreams_task, return_exceptions=True
)
btc_price_task = asyncio.create_task(update_prices_periodically())
pricing_task = asyncio.create_task(update_sats_pricing())
if global_settings.models_refresh_interval_seconds > 0:
models_refresh_task = asyncio.create_task(
refresh_upstreams_models_periodically(get_upstreams())
)
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
payout_task = asyncio.create_task(periodic_payout())
if global_settings.nsec:
nip91_task = asyncio.create_task(announce_provider())
if global_settings.providers_refresh_interval_seconds > 0:
providers_task = asyncio.create_task(providers_cache_refresher())
yield
except asyncio.CancelledError:
# Expected during shutdown
pass
except Exception as e:
logger.error(
"Application startup failed",
extra={"error": str(e), "error_type": type(e).__name__},
)
raise
finally:
logger.info("Application shutdown initiated")
if btc_price_task is not None:
btc_price_task.cancel()
if pricing_task is not None:
pricing_task.cancel()
if payout_task is not None:
payout_task.cancel()
if nip91_task is not None:
nip91_task.cancel()
if providers_task is not None:
providers_task.cancel()
if models_refresh_task is not None:
models_refresh_task.cancel()
if model_maps_refresh_task is not None:
model_maps_refresh_task.cancel()
try:
tasks_to_wait = []
if btc_price_task is not None:
tasks_to_wait.append(btc_price_task)
if pricing_task is not None:
tasks_to_wait.append(pricing_task)
if payout_task is not None:
tasks_to_wait.append(payout_task)
if nip91_task is not None:
tasks_to_wait.append(nip91_task)
if providers_task is not None:
tasks_to_wait.append(providers_task)
if models_refresh_task is not None:
tasks_to_wait.append(models_refresh_task)
if model_maps_refresh_task is not None:
tasks_to_wait.append(model_maps_refresh_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
logger.info("Background tasks stopped successfully")
except Exception as e:
logger.error(
"Error stopping background tasks",
extra={"error": str(e), "error_type": type(e).__name__},
)
app = FastAPI(version=__version__, lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=global_settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["x-routstr-request-id"],
)
# Add logging middleware
app.add_middleware(LoggingMiddleware)
# Add exception handlers
app.add_exception_handler(HTTPException, http_exception_handler) # type: ignore
app.add_exception_handler(Exception, general_exception_handler)
@app.get("/v1/info")
async def info() -> dict:
return {
"name": global_settings.name,
"description": global_settings.description,
"version": __version__,
"npub": global_settings.npub,
"mints": global_settings.cashu_mints,
"http_url": global_settings.http_url,
"onion_url": global_settings.onion_url,
"child_key_cost_msats": global_settings.child_key_cost,
}
@app.get("/v1/providers")
async def providers() -> RedirectResponse:
return RedirectResponse("/v1/providers/")
UI_DIST_PATH = Path(__file__).parent.parent.parent / "ui_out"
if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
logger.info(f"Serving static UI from {UI_DIST_PATH}")
app.mount(
"/_next",
StaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
name="next-static",
)
@app.get("/", include_in_schema=False)
async def serve_root_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "index.html")
# Add explicit route for /index.txt to redirect to /
@app.get("/index.txt", include_in_schema=False)
async def redirect_index_txt() -> RedirectResponse:
return RedirectResponse("/")
@app.get("/admin")
async def admin_redirect() -> FileResponse:
return FileResponse(UI_DIST_PATH / "index.html")
@app.get("/dashboard", include_in_schema=False)
async def serve_dashboard_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "index.html")
@app.get("/login", include_in_schema=False)
async def serve_login_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "login" / "index.html")
# Add explicit route for /login/index.txt to redirect to /login
@app.get("/login/index.txt", include_in_schema=False)
async def redirect_login_index_txt() -> RedirectResponse:
return RedirectResponse("/login")
@app.get("/model", include_in_schema=False)
async def serve_models_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "model" / "index.html")
# Add explicit route for /model/index.txt to redirect to /model
@app.get("/model/index.txt", include_in_schema=False)
async def redirect_model_index_txt() -> RedirectResponse:
return RedirectResponse("/model")
@app.get("/providers", include_in_schema=False)
async def serve_providers_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "providers" / "index.html")
# Add explicit route for /providers/index.txt to redirect to /providers
@app.get("/providers/index.txt", include_in_schema=False)
async def redirect_providers_index_txt() -> RedirectResponse:
return RedirectResponse("/providers")
@app.get("/settings", include_in_schema=False)
async def serve_settings_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "settings" / "index.html")
# Add explicit route for /settings/index.txt to redirect to /settings
@app.get("/settings/index.txt", include_in_schema=False)
async def redirect_settings_index_txt() -> RedirectResponse:
return RedirectResponse("/settings")
@app.get("/transactions", include_in_schema=False)
async def serve_transactions_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "transactions" / "index.html")
# Add explicit route for /transactions/index.txt to redirect to /transactions
@app.get("/transactions/index.txt", include_in_schema=False)
async def redirect_transactions_index_txt() -> RedirectResponse:
return RedirectResponse("/transactions")
@app.get("/balances", include_in_schema=False)
async def serve_balances_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "balances" / "index.html")
# Add explicit route for /balances/index.txt to redirect to /balances
@app.get("/balances/index.txt", include_in_schema=False)
async def redirect_balances_index_txt() -> RedirectResponse:
return RedirectResponse("/balances")
@app.get("/logs", include_in_schema=False)
async def serve_logs_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "logs" / "index.html")
# Add explicit route for /logs/index.txt to redirect to /logs
@app.get("/logs/index.txt", include_in_schema=False)
async def redirect_logs_index_txt() -> RedirectResponse:
return RedirectResponse("/logs")
@app.get("/usage", include_in_schema=False)
async def serve_usage_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "usage" / "index.html")
# Add explicit route for /usage/index.txt to redirect to /usage
@app.get("/usage/index.txt", include_in_schema=False)
async def redirect_usage_index_txt() -> RedirectResponse:
return RedirectResponse("/usage")
@app.get("/unauthorized", include_in_schema=False)
async def serve_unauthorized_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html")
# Add explicit route for /unauthorized/index.txt to redirect to /unauthorized
@app.get("/unauthorized/index.txt", include_in_schema=False)
async def redirect_unauthorized_index_txt() -> RedirectResponse:
return RedirectResponse("/unauthorized")
@app.get("/favicon.ico", include_in_schema=False)
async def serve_favicon() -> FileResponse:
icon_path = UI_DIST_PATH / "icon.ico"
if icon_path.exists():
return FileResponse(icon_path)
return FileResponse(UI_DIST_PATH / "favicon.ico")
@app.get("/icon.ico", include_in_schema=False)
async def serve_icon() -> FileResponse:
return FileResponse(UI_DIST_PATH / "icon.ico")
app.mount(
"/static", StaticFiles(directory=UI_DIST_PATH, check_dir=True), name="ui-static"
)
else:
logger.warning(
f"UI dist directory not found at {UI_DIST_PATH}, skipping static file serving"
)
@app.get("/", include_in_schema=False)
async def root_fallback() -> dict:
return {
"name": global_settings.name,
"description": global_settings.description,
"version": __version__,
"status": "running",
"ui": "not available",
}
app.include_router(models_router)
app.include_router(admin_router)
app.include_router(balance_router)
app.include_router(deprecated_wallet_router)
app.include_router(providers_router)
app.include_router(proxy_router)
-135
View File
@@ -1,135 +0,0 @@
import time
import uuid
from contextvars import ContextVar
from typing import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from .logging import get_logger
logger = get_logger(__name__)
# Context variable to store request ID across async context
request_id_context: ContextVar[str | None] = ContextVar("request_id")
class LoggingMiddleware(BaseHTTPMiddleware):
"""Middleware to log detailed request and response information."""
async def dispatch(self, request: Request, call_next: Callable) -> Response:
# Generate request ID
request_id = str(uuid.uuid4())
request.state.request_id = request_id
# Set request ID in context for logging
token = request_id_context.set(request_id)
# Start timing
start_time = time.time()
# Log request details
request_body = None
if request.method in ["POST", "PUT", "PATCH"]:
try:
# Only read body for non-streaming requests
if hasattr(request, "_body"):
request_body = await request.body()
except Exception:
pass
# Extract request info
client_host = None
if request.client:
client_host = request.client.host
# Log incoming request
logger.info(
"Incoming request",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"query_params": dict(request.query_params),
"client_host": client_host,
"headers": {
k: v
for k, v in request.headers.items()
if k.lower()
not in [
"authorization",
"x-cashu",
"cookie",
"cf-connecting-ip",
"cf-ipcountry",
"x-forwarded-for",
"x-real-ip",
]
},
"body_size": len(request_body) if request_body else 0,
},
)
# Log at TRACE level for full body (security filter will redact sensitive data)
if request_body and hasattr(logger, "exception"):
logger.exception(
"Request body",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"body": request_body.decode("utf-8", errors="ignore")[
:1000
], # Limit size
},
)
# Process request
try:
response = await call_next(request)
# Calculate duration
duration = time.time() - start_time
# Log response
logger.info(
"Request completed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": round(duration * 1000, 2),
"client_host": client_host,
},
)
if hasattr(response, "headers"):
response.headers["x-routstr-request-id"] = request_id
return response
except Exception as e:
# Calculate duration
duration = time.time() - start_time
# Log error
logger.error(
"Request failed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"duration_ms": round(duration * 1000, 2),
"client_host": client_host,
"error": str(e),
"error_type": type(e).__name__,
},
exc_info=True,
)
raise
finally:
# Reset context
request_id_context.reset(token)
__all__ = ["LoggingMiddleware", "request_id_context"]
-312
View File
@@ -1,312 +0,0 @@
from __future__ import annotations
import asyncio
import json
import os
from datetime import datetime, timezone
from typing import Any
from pydantic.v1 import BaseModel, BaseSettings, Field
from sqlmodel.ext.asyncio.session import AsyncSession
class Settings(BaseSettings):
class Config:
case_sensitive = True
@classmethod
def parse_env_var(cls, field_name: str, raw_value: str) -> Any: # type: ignore[override]
if field_name in {"cashu_mints", "cors_origins", "relays"}:
v = str(raw_value).strip()
if v == "":
return []
return [p.strip() for p in v.split(",") if p.strip()]
return raw_value
# Core
upstream_base_url: str = Field(default="", env="UPSTREAM_BASE_URL")
upstream_api_key: str = Field(default="", env="UPSTREAM_API_KEY")
admin_password: str = Field(default="", env="ADMIN_PASSWORD")
# Node info
name: str = Field(default="ARoutstrNode", env="NAME")
description: str = Field(default="A Routstr Node", env="DESCRIPTION")
npub: str = Field(default="", env="NPUB")
http_url: str = Field(default="", env="HTTP_URL")
onion_url: str = Field(default="", env="ONION_URL")
# Cashu
cashu_mints: list[str] = Field(default_factory=list, env="CASHU_MINTS")
receive_ln_address: str = Field(default="", env="RECEIVE_LN_ADDRESS")
primary_mint: str = Field(default="", env="PRIMARY_MINT_URL")
primary_mint_unit: str = Field(default="sat", env="PRIMARY_MINT_UNIT")
# Pricing
# Default behavior: derive pricing from MODELS
# If fixed_pricing is True -> use fixed_cost_per_request and ignore tokens
# If fixed_per_1k_* are set (non-zero) -> override model token pricing when model-based
fixed_pricing: bool = Field(default=False, env="FIXED_PRICING")
fixed_cost_per_request: int = Field(default=1, env="FIXED_COST_PER_REQUEST")
fixed_per_1k_input_tokens: int = Field(default=0, env="FIXED_PER_1K_INPUT_TOKENS")
fixed_per_1k_output_tokens: int = Field(default=0, env="FIXED_PER_1K_OUTPUT_TOKENS")
exchange_fee: float = Field(default=1.005, env="EXCHANGE_FEE")
upstream_provider_fee: float = Field(default=1.05, env="UPSTREAM_PROVIDER_FEE")
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
child_key_cost: int = Field(default=1000, env="CHILD_KEY_COST")
# Minimum per-request charge in millisatoshis when model pricing is free/zero
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
reset_reserved_balance_on_startup: bool = Field(
default=True, env="RESET_RESERVED_BALANCE_ON_STARTUP"
) # deactivate in horizontal scaling setups
# Network
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
tor_proxy_url: str = Field(default="socks5://127.0.0.1:9050", env="TOR_PROXY_URL")
providers_refresh_interval_seconds: int = Field(
default=0, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
)
pricing_refresh_interval_seconds: int = Field(
default=120, env="PRICING_REFRESH_INTERVAL_SECONDS"
)
models_refresh_interval_seconds: int = Field(
default=360, env="MODELS_REFRESH_INTERVAL_SECONDS"
)
enable_pricing_refresh: bool = Field(default=True, env="ENABLE_PRICING_REFRESH")
enable_models_refresh: bool = Field(default=True, env="ENABLE_MODELS_REFRESH")
refund_cache_ttl_seconds: int = Field(default=3600, env="REFUND_CACHE_TTL_SECONDS")
# Logging
log_level: str = Field(default="INFO", env="LOG_LEVEL")
enable_console_logging: bool = Field(default=True, env="ENABLE_CONSOLE_LOGGING")
# Other
chat_completions_api_version: str = Field(
default="", env="CHAT_COMPLETIONS_API_VERSION"
)
models_path: str = Field(default="models.json", env="MODELS_PATH")
source: str = Field(default="", env="SOURCE")
# Secrets / optional runtime controls
provider_id: str = Field(default="", env="PROVIDER_ID")
nsec: str = Field(default="", env="NSEC")
# Discovery
relays: list[str] = Field(default_factory=list, env="RELAYS")
def _compute_primary_mint(cashu_mints: list[str]) -> str:
return cashu_mints[0] if cashu_mints else "https://mint.minibits.cash/Bitcoin"
def resolve_bootstrap() -> Settings:
base = Settings() # Reads env with custom parse_env_var
# Back-compat env mapping
try:
# Map MODEL_BASED_PRICING -> fixed_pricing (inverted)
if "MODEL_BASED_PRICING" in os.environ and "FIXED_PRICING" not in os.environ:
mbp_raw = os.environ.get("MODEL_BASED_PRICING", "").strip().lower()
mbp = mbp_raw in {"1", "true", "yes", "on"}
base.fixed_pricing = not mbp
# Map COST_PER_REQUEST -> fixed_cost_per_request if new not provided
if (
"COST_PER_REQUEST" in os.environ
and "FIXED_COST_PER_REQUEST" not in os.environ
):
try:
base.fixed_cost_per_request = int(
os.environ["COST_PER_REQUEST"].strip()
)
except Exception:
pass
# Map COST_PER_1K_* -> FIXED_PER_1K_*
if (
"COST_PER_1K_INPUT_TOKENS" in os.environ
and "FIXED_PER_1K_INPUT_TOKENS" not in os.environ
):
try:
base.fixed_per_1k_input_tokens = int(
os.environ["COST_PER_1K_INPUT_TOKENS"].strip()
)
except Exception:
pass
if (
"COST_PER_1K_OUTPUT_TOKENS" in os.environ
and "FIXED_PER_1K_OUTPUT_TOKENS" not in os.environ
):
try:
base.fixed_per_1k_output_tokens = int(
os.environ["COST_PER_1K_OUTPUT_TOKENS"].strip()
)
except Exception:
pass
except Exception:
pass
if not base.onion_url:
try:
from ..nip91 import discover_onion_url_from_tor # type: ignore
discovered = discover_onion_url_from_tor()
if discovered:
base.onion_url = discovered
except Exception:
pass
# Derive NPUB from NSEC if not provided
if not base.npub and base.nsec:
try:
from nostr.key import PrivateKey # type: ignore
if base.nsec.startswith("nsec"):
pk = PrivateKey.from_nsec(base.nsec)
elif len(base.nsec) == 64:
pk = PrivateKey(bytes.fromhex(base.nsec))
else:
pk = None
if pk is not None:
try:
base.npub = pk.public_key.bech32()
except Exception:
# Fallback to hex if bech32 not available
base.npub = pk.public_key.hex()
except Exception:
pass
if not base.cors_origins:
base.cors_origins = ["*"]
if not base.primary_mint:
base.primary_mint = _compute_primary_mint(base.cashu_mints)
return base
class SettingsRow(BaseModel):
id: int
data: dict[str, Any]
updated_at: datetime | None = None
# Single, concrete settings instance that callers import directly
settings: Settings = resolve_bootstrap()
class SettingsService:
_current: Settings | None = None
_lock: asyncio.Lock = asyncio.Lock()
@classmethod
def get(cls) -> Settings:
if cls._current is None:
raise RuntimeError("SettingsService not initialized")
return cls._current
@classmethod
async def initialize(cls, db_session: AsyncSession) -> Settings:
async with cls._lock:
from sqlmodel import text
await db_session.exec( # type: ignore
text(
"CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY, data TEXT NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"
)
)
row = await db_session.exec( # type: ignore
text("SELECT id, data, updated_at FROM settings WHERE id = 1")
)
row = row.first()
env_resolved = resolve_bootstrap()
if row is None:
await db_session.exec( # type: ignore
text(
"INSERT INTO settings (id, data, updated_at) VALUES (1, :data, :updated_at)"
).bindparams(
data=json.dumps(env_resolved.dict()),
updated_at=datetime.now(timezone.utc),
)
)
await db_session.commit()
cls._current = settings
# Update the existing instance in-place for all live importers
for k, v in env_resolved.dict().items():
setattr(settings, k, v)
return cls._current
db_id, db_data, _updated_at = row
try:
db_json = (
json.loads(db_data) if isinstance(db_data, str) else dict(db_data)
)
except Exception:
db_json = {}
merged_dict: dict[str, Any] = dict(env_resolved.dict())
merged_dict.update(
{k: v for k, v in db_json.items() if v not in (None, "", [], {})}
)
# Ensure primary_mint is consistent with cashu_mints if not explicitly set
if not merged_dict.get("primary_mint"):
merged_dict["primary_mint"] = _compute_primary_mint(
merged_dict.get("cashu_mints", [])
)
if any(k not in db_json for k in merged_dict.keys()):
await db_session.exec( # type: ignore
text(
"UPDATE settings SET data = :data, updated_at = :updated_at WHERE id = 1"
).bindparams(
data=json.dumps(merged_dict),
updated_at=datetime.now(timezone.utc),
)
)
await db_session.commit()
# Update the existing instance in-place for all live importers
for k, v in merged_dict.items():
setattr(settings, k, v)
cls._current = settings
return cls._current
@classmethod
async def update(
cls, partial: dict[str, Any], db_session: AsyncSession
) -> Settings:
async with cls._lock:
current = cls.get()
candidate_dict = {**current.dict(), **partial}
candidate = Settings(**candidate_dict)
from sqlmodel import text
# Ensure primary_mint reflects candidate mints if missing
if not candidate.primary_mint:
candidate.primary_mint = _compute_primary_mint(candidate.cashu_mints)
await db_session.exec( # type: ignore
text(
"UPDATE settings SET data = :data, updated_at = :updated_at WHERE id = 1"
).bindparams(
data=json.dumps(candidate.dict()),
updated_at=datetime.now(timezone.utc),
)
)
await db_session.commit()
# Update in-place
for k, v in candidate.dict().items():
setattr(settings, k, v)
cls._current = settings
return settings
@classmethod
async def reload_from_db(cls, db_session: AsyncSession) -> Settings:
async with cls._lock:
from sqlmodel import text
row = await db_session.exec(text("SELECT data FROM settings WHERE id = 1")) # type: ignore
row = row.first()
if row is None:
raise RuntimeError("Settings row missing")
(data_str,) = row
data = json.loads(data_str) if isinstance(data_str, str) else dict(data_str)
# Update in-place
for k, v in data.items():
setattr(settings, k, v)
cls._current = settings
return settings
-404
View File
@@ -1,404 +0,0 @@
import asyncio
import json
import random
import string
from typing import Any
import httpx
import websockets
from fastapi import APIRouter, HTTPException
from .core.logging import get_logger
from .core.settings import settings
logger = get_logger(__name__)
providers_router = APIRouter(prefix="/v1/providers")
# In-memory providers cache and lock
_PROVIDERS_CACHE: list[dict[str, Any]] = []
_PROVIDERS_CACHE_LOCK = asyncio.Lock()
def generate_subscription_id() -> str:
"""Generate a random subscription ID."""
return "".join(random.choices(string.ascii_lowercase + string.digits, k=10))
async def query_nostr_relay_for_providers(
relay_url: str,
pubkey: str | None = None,
limit: int = 1000,
timeout: int = 30,
) -> list[dict[str, Any]]:
"""
Query a Nostr relay for provider announcements.
Searches for NIP-91 (kind:38421) events.
"""
events = []
# Build filter for NIP-91 events
filter_obj: dict[str, Any] = {
"kinds": [38421], # NIP-91 Provider Announcements
"limit": limit,
}
# If specific pubkey provided, filter by author
if pubkey:
filter_obj["authors"] = [pubkey]
sub_id = generate_subscription_id()
req_message = json.dumps(["REQ", sub_id, filter_obj])
try:
async with websockets.connect(relay_url, open_timeout=timeout) as websocket:
logger.debug("Connected to relay, searching for NIP-91 events (kind 38421)")
await websocket.send(req_message)
while True:
try:
message = await asyncio.wait_for(websocket.recv(), timeout=5)
data = json.loads(message)
if data[0] == "EVENT" and data[1] == sub_id:
event = data[2]
logger.debug(
f"Found provider announcement: {event['id'][:6]}...{event['id'][-6:]}"
)
events.append(event)
elif data[0] == "EOSE" and data[1] == sub_id:
logger.debug("Received EOSE message")
break
elif data[0] == "NOTICE":
try:
msg = str(data[1])
if len(msg) > 200:
msg = msg[:200] + "..."
logger.debug(f"Relay notice: {msg}")
except Exception:
logger.debug("Relay notice received")
except asyncio.TimeoutError:
logger.debug("Timeout waiting for message")
break
except json.JSONDecodeError:
logger.warning("Failed to decode message as JSON")
continue
await websocket.send(json.dumps(["CLOSE", sub_id]))
except Exception as e:
logger.debug(f"Query failed: {type(e).__name__}")
logger.info(f"Query complete. Found {len(events)} provider announcements")
return events
def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
"""
Parse provider announcement events.
Handles NIP-91 (kind:38421) format.
Returns structured provider data or None if invalid.
"""
try:
tags = event.get("tags", [])
kind = event.get("kind")
# Common fields
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]
# Early validation only applies to legacy/other kinds, not NIP-91
if kind != 38421 and (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
mint_urls = []
version = None
# Parse NIP-91 format
if kind == 38421: # NIP-91 format
for tag in tags:
if len(tag) >= 2:
if tag[0] == "d":
d_tag = tag[1]
elif tag[0] == "u":
endpoint_urls.append(tag[1])
elif tag[0] == "mint":
mint_urls.append(tag[1])
elif tag[0] == "version":
version = tag[1]
# Parse metadata from content for NIP-91
content = event.get("content", "")
if content:
try:
metadata = json.loads(content)
provider_name = metadata.get("name", "Unknown Provider")
description = metadata.get("about")
except (json.JSONDecodeError, TypeError):
provider_name = "Unknown Provider"
else:
provider_name = "Unknown Provider"
# Use first URL as primary endpoint
endpoint_url = endpoint_urls[0] if endpoint_urls else None
# Validate NIP-91 required fields
if not endpoint_url or not d_tag:
logger.warning(
f"Invalid NIP-91 announcement - missing required fields: {event['id']}"
)
return None
else:
logger.warning(
f"Unknown event kind when parsing provider announcement: {kind}"
)
return None
return {
"id": d_tag,
"pubkey": event["pubkey"],
"created_at": event["created_at"],
"kind": kind,
"endpoint_url": endpoint_url,
"endpoint_urls": endpoint_urls, # All URLs for NIP-91
"name": provider_name,
"description": description,
"mint_urls": mint_urls,
"version": version,
"content": event.get("content", ""),
}
except Exception as e:
logger.error(
f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}"
)
return None
async def get_cache() -> list[dict[str, Any]]:
async with _PROVIDERS_CACHE_LOCK:
return list(_PROVIDERS_CACHE)
def _get_discovery_relays() -> list[str]:
try:
relays = settings.relays
except Exception:
relays = []
if not relays:
relays = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://relay.routstr.com",
"wss://nos.lol",
]
return relays
async def _discover_providers(pubkey: str | None = None) -> list[dict[str, Any]]:
discovery_relays = _get_discovery_relays()
tasks = [
query_nostr_relay_for_providers(relay_url=r, pubkey=pubkey, limit=100)
for r in discovery_relays
]
results = await asyncio.gather(*tasks, return_exceptions=True)
all_events: list[dict[str, Any]] = []
event_ids: set[str] = set()
for res in results:
if isinstance(res, BaseException):
logger.error(f"Relay query failed: {res}")
continue
if isinstance(res, list):
for event in res:
# Filter out localhost announcements
try:
tags = event.get("tags", [])
is_localhost = any(
isinstance(tag, list)
and len(tag) >= 2
and tag[0] == "u"
and tag[1] == "http://localhost:8000"
for tag in tags
)
if is_localhost:
logger.debug(
f"Skipping localhost provider event: {event.get('id', 'unknown')}"
)
continue
except Exception:
# If tags are malformed, fall through to normal handling
pass
if (eid := event.get("id")) and eid not in event_ids:
event_ids.add(eid)
all_events.append(event)
else:
logger.error(f"Unexpected relay result type: {type(res)}")
providers: list[dict[str, Any]] = []
seen_endpoints: set[str] = set()
for event in all_events:
parsed = parse_provider_announcement(event)
if parsed and (eu := parsed.get("endpoint_url")) and eu not in seen_endpoints:
seen_endpoints.add(eu)
providers.append(parsed)
random.shuffle(providers)
return providers[:42]
async def refresh_providers_cache(pubkey: str | None = None) -> None:
try:
providers = await _discover_providers(pubkey=pubkey)
health_tasks = [
fetch_provider_health(provider["endpoint_url"]) for provider in providers
]
health_results = await asyncio.gather(*health_tasks, return_exceptions=True)
new_cache: list[dict[str, Any]] = []
for provider, hr in zip(providers, health_results):
if isinstance(hr, Exception):
health: dict[str, Any] = {
"status_code": 500,
"endpoint": "error",
"json": {"error": str(hr)},
}
else:
health = hr # type: ignore[assignment]
new_cache.append({"provider": provider, "health": health})
async with _PROVIDERS_CACHE_LOCK:
_PROVIDERS_CACHE.clear()
_PROVIDERS_CACHE.extend(new_cache)
logger.info(
f"Providers cache refreshed with {len(new_cache)} entries (limit 42)"
)
except Exception as e:
logger.error(f"Failed to refresh providers cache: {e}")
async def providers_cache_refresher(
interval_seconds: int | None = None, pubkey: str | None = None
) -> None:
if interval_seconds is None:
try:
interval_seconds = settings.providers_refresh_interval_seconds
except Exception:
interval_seconds = 300
await refresh_providers_cache(pubkey=pubkey)
while True:
try:
await asyncio.sleep(interval_seconds)
except asyncio.CancelledError:
break
await refresh_providers_cache(pubkey=pubkey)
async def fetch_provider_health(endpoint_url: str) -> dict[str, Any]:
"""Fetch provider health and info, preferring /v1/info for models and pricing."""
try:
# Determine if we need Tor proxy based on .onion domain
is_onion = ".onion" in endpoint_url
# Set up client arguments conditionally
proxies = None
if is_onion:
try:
tor_proxy = settings.tor_proxy_url
except Exception:
tor_proxy = "socks5://127.0.0.1:9050"
proxies = {"http://": tor_proxy, "https://": tor_proxy} # type: ignore[assignment]
async with httpx.AsyncClient(
timeout=httpx.Timeout(30.0),
follow_redirects=True,
proxies=proxies, # type: ignore[arg-type]
) as client:
# Prefer provider's /v1/info for full details
info_url = f"{endpoint_url.rstrip('/')}/v1/info"
try:
response = await client.get(info_url)
if response.status_code == 200:
return {
"status_code": response.status_code,
"endpoint": "info",
"json": response.json(),
}
except Exception:
pass
# Fallback to /v1/models
models_url = f"{endpoint_url.rstrip('/')}/v1/models"
try:
response = await client.get(models_url)
if response.status_code == 200:
return {
"status_code": response.status_code,
"endpoint": "models",
"json": response.json(),
}
except Exception:
pass
# Fallback to root endpoint
response = await client.get(endpoint_url)
return {
"status_code": response.status_code,
"endpoint": "root",
"json": response.json()
if response.headers.get("content-type", "").startswith(
"application/json"
)
else {"message": "OK"},
}
except Exception as e:
return {
"status_code": 500,
"endpoint": "error",
"json": {"error": f"Failed to fetch provider: {str(e)}"},
}
@providers_router.get("/")
async def get_providers(
include_json: bool = False, pubkey: str | None = None
) -> dict[str, list[dict[str, Any]]]:
"""
Return cached providers. If include_json, return provider+health; otherwise provider only.
Optional filter by pubkey.
"""
if settings.providers_refresh_interval_seconds == 0:
raise HTTPException(status_code=404, detail="Provider discovery is disabled")
cache = await get_cache()
if not cache:
await refresh_providers_cache(pubkey=pubkey)
cache = await get_cache()
if pubkey:
cache = [c for c in cache if c.get("provider", {}).get("pubkey") == pubkey]
if include_json:
return {"providers": cache}
providers_only = [c["provider"] for c in cache]
return {"providers": providers_only}
-270
View File
@@ -1,270 +0,0 @@
import hashlib
import secrets
import time
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from .core.db import ApiKey, LightningInvoice, get_session
from .core.logging import get_logger
from .core.settings import settings
from .wallet import get_wallet
logger = get_logger(__name__)
lightning_router = APIRouter(prefix="/lightning")
class InvoiceCreateRequest(BaseModel):
amount_sats: int = Field(gt=0, le=1_000_000, description="Amount in satoshis")
purpose: str = Field(description="create or topup", pattern="^(create|topup)$")
api_key: str | None = Field(
default=None, description="Required for topup operations"
)
class InvoiceCreateResponse(BaseModel):
invoice_id: str
bolt11: str
amount_sats: int
expires_at: int
payment_hash: str
class InvoiceStatusResponse(BaseModel):
status: str
api_key: str | None = None
amount_sats: int
paid_at: int | None = None
created_at: int
expires_at: int
class InvoiceRecoverRequest(BaseModel):
bolt11: str = Field(description="BOLT11 invoice string")
async def generate_lightning_invoice(
amount_sats: int, description: str
) -> tuple[str, str]:
wallet = await get_wallet(settings.primary_mint, "sat")
quote = await wallet.request_mint(amount_sats)
return quote.request, quote.quote
def generate_invoice_id() -> str:
return secrets.token_urlsafe(16)
@lightning_router.post("/invoice", response_model=InvoiceCreateResponse)
async def create_invoice(
request: InvoiceCreateRequest,
session: AsyncSession = Depends(get_session),
) -> InvoiceCreateResponse:
if request.purpose == "topup" and not request.api_key:
raise HTTPException(
status_code=400, detail="api_key is required for topup operations"
)
if request.purpose == "topup" and request.api_key:
if not request.api_key.startswith("sk-"):
raise HTTPException(status_code=400, detail="Invalid API key format")
api_key = await session.get(ApiKey, request.api_key[3:])
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")
try:
description = f"Routstr {request.purpose} {request.amount_sats} sats"
bolt11, payment_hash = await generate_lightning_invoice(
request.amount_sats, description
)
invoice_id = generate_invoice_id()
expires_at = int(time.time()) + 3600 # 1 hour expiry
invoice = LightningInvoice(
id=invoice_id,
bolt11=bolt11,
amount_sats=request.amount_sats,
description=description,
payment_hash=payment_hash,
status="pending",
api_key_hash=request.api_key[3:] if request.api_key else None,
purpose=request.purpose,
expires_at=expires_at,
)
session.add(invoice)
await session.commit()
logger.info(
"Lightning invoice created",
extra={
"invoice_id": invoice_id,
"amount_sats": request.amount_sats,
"purpose": request.purpose,
"expires_at": expires_at,
},
)
return InvoiceCreateResponse(
invoice_id=invoice_id,
bolt11=bolt11,
amount_sats=request.amount_sats,
expires_at=expires_at,
payment_hash=payment_hash,
)
except Exception as e:
logger.error(f"Failed to create Lightning invoice: {e}")
raise HTTPException(
status_code=500, detail="Failed to create Lightning invoice"
)
@lightning_router.get(
"/invoice/{invoice_id}/status", response_model=InvoiceStatusResponse
)
async def get_invoice_status(
invoice_id: str,
session: AsyncSession = Depends(get_session),
) -> InvoiceStatusResponse:
invoice = await session.get(LightningInvoice, invoice_id)
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
if invoice.status == "pending" and int(time.time()) > invoice.expires_at:
invoice.status = "expired"
await session.commit()
if invoice.status == "pending":
await check_invoice_payment(invoice, session)
api_key = None
if invoice.status == "paid" and invoice.purpose == "create":
if invoice.api_key_hash:
api_key = f"sk-{invoice.api_key_hash}"
elif (
invoice.status == "paid" and invoice.purpose == "topup" and invoice.api_key_hash
):
api_key = f"sk-{invoice.api_key_hash}"
return InvoiceStatusResponse(
status=invoice.status,
api_key=api_key,
amount_sats=invoice.amount_sats,
paid_at=invoice.paid_at,
created_at=invoice.created_at,
expires_at=invoice.expires_at,
)
@lightning_router.post("/recover", response_model=InvoiceStatusResponse)
async def recover_invoice(
request: InvoiceRecoverRequest,
session: AsyncSession = Depends(get_session),
) -> InvoiceStatusResponse:
result = await session.exec(
select(LightningInvoice).where(LightningInvoice.bolt11 == request.bolt11)
)
invoice = result.first()
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
if invoice.status == "pending":
await check_invoice_payment(invoice, session)
api_key = None
if invoice.status == "paid":
if invoice.purpose == "create" and invoice.api_key_hash:
api_key = f"sk-{invoice.api_key_hash}"
elif invoice.purpose == "topup" and invoice.api_key_hash:
api_key = f"sk-{invoice.api_key_hash}"
return InvoiceStatusResponse(
status=invoice.status,
api_key=api_key,
amount_sats=invoice.amount_sats,
paid_at=invoice.paid_at,
created_at=invoice.created_at,
expires_at=invoice.expires_at,
)
async def check_invoice_payment(
invoice: LightningInvoice, session: AsyncSession
) -> None:
try:
wallet = await get_wallet(settings.primary_mint, "sat")
mint_status = await wallet.get_mint_quote(invoice.payment_hash)
if mint_status.paid:
invoice.status = "paid"
invoice.paid_at = int(time.time())
if invoice.purpose == "create":
api_key = await create_api_key_from_invoice(invoice, session)
invoice.api_key_hash = api_key.hashed_key
elif invoice.purpose == "topup" and invoice.api_key_hash:
await topup_api_key_from_invoice(invoice, session)
await session.commit()
logger.info(
"Lightning invoice paid",
extra={
"invoice_id": invoice.id,
"amount_sats": invoice.amount_sats,
"purpose": invoice.purpose,
"api_key_hash": invoice.api_key_hash[:8] + "..."
if invoice.api_key_hash
else None,
},
)
except Exception as e:
logger.error(f"Failed to check invoice payment: {e}")
async def create_api_key_from_invoice(
invoice: LightningInvoice, session: AsyncSession
) -> ApiKey:
wallet = await get_wallet(settings.primary_mint, "sat")
await wallet.mint(invoice.amount_sats, quote_id=invoice.payment_hash)
dummy_token = f"invoice-{invoice.id}-{invoice.payment_hash}"
hashed_key = hashlib.sha256(dummy_token.encode()).hexdigest()
api_key = ApiKey(
hashed_key=hashed_key,
balance=invoice.amount_sats * 1000, # Convert to msats
refund_currency="sat",
refund_mint_url=settings.primary_mint,
)
session.add(api_key)
await session.flush()
return api_key
async def topup_api_key_from_invoice(
invoice: LightningInvoice, session: AsyncSession
) -> None:
wallet = await get_wallet(settings.primary_mint, "sat")
await wallet.mint(invoice.amount_sats, quote_id=invoice.payment_hash)
if not invoice.api_key_hash:
raise ValueError("No API key associated with topup invoice")
api_key = await session.get(ApiKey, invoice.api_key_hash)
if not api_key:
raise ValueError("Associated API key not found")
api_key.balance += invoice.amount_sats * 1000 # Convert to msats
await session.flush()
-574
View File
@@ -1,574 +0,0 @@
#!/usr/bin/env python3
"""
NIP-91: Routstr Provider Discoverability Implementation
Automatically announces this Routstr proxy instance to Nostr relays.
"""
import asyncio
import json
import os
import random
import ssl
import time
from typing import Any, cast
from nostr.event import Event
from nostr.filter import Filter, Filters
from nostr.key import PrivateKey
from nostr.message_type import ClientMessageType
from nostr.relay_manager import RelayManager
from .core import get_logger
from .core.settings import settings
logger = get_logger(__name__)
def get_app_version() -> str | None:
try:
from .core.main import __version__ as imported_version
return imported_version
except Exception:
return None
def _event_to_dict(ev: Event) -> dict[str, Any]:
return {
"id": ev.id,
"pubkey": ev.public_key,
"created_at": ev.created_at,
"kind": int(ev.kind) if not isinstance(ev.kind, int) else ev.kind,
"tags": ev.tags,
"content": ev.content,
"sig": ev.signature,
}
def nsec_to_keypair(nsec: str) -> tuple[str, str] | None:
"""
Convert a Nostr private key (nsec) to a keypair (privkey_hex, pubkey_hex).
Args:
nsec: Nostr private key in nsec format or hex format
Returns:
Tuple of (private_key_hex, public_key_hex) or None if invalid
"""
try:
if nsec.startswith("nsec"):
pk = PrivateKey.from_nsec(nsec)
return (pk.hex(), pk.public_key.hex())
if len(nsec) == 64:
pk = PrivateKey(bytes.fromhex(nsec))
return (pk.hex(), pk.public_key.hex())
logger.error(f"Invalid private key format/length: {len(nsec)}")
return None
except Exception as e:
logger.error(f"Failed to convert nsec to keypair: {e}")
return None
def create_nip91_event(
private_key_hex: str,
provider_id: str,
endpoint_urls: list[str],
mint_urls: list[str] | None = None,
version: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Create a NIP-91 compliant provider announcement event (kind:38421).
Args:
private_key_hex: 32-byte hex private key for signing
provider_id: Unique identifier for this provider (d tag)
endpoint_urls: List of URLs to connect to the provider
mint_urls: Optional list of ecash mint URLs for payments
version: Provider software version
metadata: Optional metadata dictionary (name, picture, about, etc.)
Returns:
Complete signed nostr event as a dict ready for publishing
"""
pk = PrivateKey(bytes.fromhex(private_key_hex))
tags = [["d", provider_id]]
for url in endpoint_urls:
tags.append(["u", url])
if mint_urls:
for m in mint_urls:
if m:
tags.append(["mint", m])
if version:
tags.append(["version", version])
content = json.dumps(metadata, separators=(",", ":")) if metadata else ""
ev = Event(pk.public_key.hex(), content, kind=38421, tags=tags)
pk.sign_event(ev)
return _event_to_dict(ev)
def _get_tag_values(event: dict[str, Any], key: str) -> list[str]:
tags = event.get("tags", [])
values: list[str] = []
for tag in tags:
if isinstance(tag, list) and tag and tag[0] == key and len(tag) >= 2:
values.append(tag[1])
return values
def _get_single_tag_value(event: dict[str, Any], key: str) -> str | None:
values = _get_tag_values(event, key)
return values[0] if values else None
def _parse_content_json(content: str) -> dict[str, Any]:
if not content:
return {}
try:
parsed = json.loads(content)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
def events_semantically_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
if a.get("kind") != b.get("kind"):
return False
if _get_single_tag_value(a, "d") != _get_single_tag_value(b, "d"):
return False
urls_a = set(_get_tag_values(a, "u"))
urls_b = set(_get_tag_values(b, "u"))
if urls_a != urls_b:
return False
mints_a = set(_get_tag_values(a, "mint"))
mints_b = set(_get_tag_values(b, "mint"))
if mints_a != mints_b:
return False
if _get_single_tag_value(a, "version") != _get_single_tag_value(b, "version"):
return False
content_a = _parse_content_json(cast(str, a.get("content", "")))
content_b = _parse_content_json(cast(str, b.get("content", "")))
if content_a != content_b:
return False
return True
async def query_nip91_events(
relay_url: str,
pubkey: str,
provider_id: str | None = None,
timeout: int = 30,
) -> tuple[list[dict[str, Any]], bool]:
"""
Query a Nostr relay for NIP-91 provider announcements (kind:38421) via nostr library.
Returns a tuple of (events, ok) where ok indicates whether the relay interaction
succeeded without transport-level errors.
"""
def _sync_query() -> tuple[list[dict[str, Any]], bool]:
rm = RelayManager()
rm.add_relay(relay_url)
events_out: list[dict[str, Any]] = []
ok = True
try:
rm.open_connections({"cert_reqs": ssl.CERT_NONE})
time.sleep(1.0)
flt = Filter(kinds=[38421], authors=[pubkey], limit=10)
filters = Filters([flt])
sub_id = f"nip91_{int(time.time())}"
rm.add_subscription(sub_id, filters)
req: list[Any] = [ClientMessageType.REQUEST, sub_id]
req.extend(filters.to_json_array())
rm.publish_message(json.dumps(req))
start = time.time()
last_event_ts = start
while time.time() - start < timeout:
drained = False
while rm.message_pool.has_events():
drained = True
ev_msg = rm.message_pool.get_event()
ev = ev_msg.event
ev_dict = _event_to_dict(ev)
if provider_id is not None:
tags = ev_dict.get("tags", [])
if not any(
isinstance(t, list)
and len(t) >= 2
and t[0] == "d"
and t[1] == provider_id
for t in tags
):
continue
events_out.append(ev_dict)
logger.debug(
f"Found listing event: {ev_dict.get('id', '')[:6]}...{ev_dict.get('id', '')[-6:]}"
)
if drained:
last_event_ts = time.time()
while rm.message_pool.has_notices():
notice = rm.message_pool.get_notice()
try:
content = getattr(notice, "content", notice)
s = str(content)
if len(s) > 200:
s = s[:200] + "..."
logger.debug(f"Relay notice: {s}")
except Exception:
pass
if time.time() - last_event_ts > 2.5:
break
time.sleep(0.1)
except Exception as e:
ok = False
logger.debug(f"Failed to query relay {relay_url}: {type(e).__name__}")
finally:
try:
rm.close_connections()
except Exception:
pass
return events_out, ok
return await asyncio.to_thread(_sync_query)
def discover_onion_url_from_tor(base_dir: str = "/var/lib/tor") -> str | None:
"""Discover onion URL by reading Tor hidden service hostname files.
Tries common paths first, then scans recursively for any 'hostname' file.
Returns an http URL like 'http://<host>.onion' if found.
"""
common_candidates = [
os.path.join(base_dir, "hs", "router", "hostname"),
os.path.join(base_dir, "hs", "ROUTER", "hostname"),
os.path.join(base_dir, "hidden_service", "hostname"),
]
for candidate in common_candidates:
try:
with open(candidate, "r", encoding="utf-8") as f:
host = f.readline().strip()
if host and host.endswith(".onion"):
return f"http://{host}"
except Exception:
pass
try:
for root, _dirs, files in os.walk(base_dir):
if "hostname" in files:
path = os.path.join(root, "hostname")
try:
with open(path, "r", encoding="utf-8") as f:
host = f.readline().strip()
if host and host.endswith(".onion"):
return f"http://{host}"
except Exception:
continue
except Exception:
pass
return None
async def _determine_provider_id(public_key_hex: str, relay_urls: list[str]) -> str:
explicit = settings.provider_id
if explicit:
logger.info(f"Using configured provider_id from env: {explicit}")
return explicit
async def query_single_relay(relay_url: str) -> list[dict[str, Any]]:
try:
events, _ok = await query_nip91_events(relay_url, public_key_hex, None)
return events
except Exception:
return []
# Query all relays concurrently
all_events_lists = await asyncio.gather(
*[query_single_relay(relay_url) for relay_url in relay_urls]
)
latest_event: dict[str, Any] | None = None
latest_ts = -1
for events_list in all_events_lists:
for ev in events_list:
ts = int(ev.get("created_at", 0))
if ts > latest_ts:
latest_event = ev
latest_ts = ts
existing_d = _get_single_tag_value(latest_event, "d") if latest_event else None
if existing_d:
logger.info(f"Reusing existing provider_id from relay: {existing_d}")
return existing_d
fallback = public_key_hex[:12]
logger.info(f"No existing provider_id found; using fallback: {fallback}")
return fallback
async def publish_to_relay(
relay_url: str,
event: dict[str, Any],
timeout: int = 30,
) -> bool:
"""
Publish a NIP-91 event to a nostr relay via nostr library.
"""
def _sync_publish() -> bool:
rm = RelayManager()
rm.add_relay(relay_url)
try:
rm.open_connections({"cert_reqs": ssl.CERT_NONE})
time.sleep(1.0)
# Publish the event as-is via publish_message to preserve signature
rm.publish_message(json.dumps(["EVENT", event]))
logger.debug(f"Sent NIP-91 event {event.get('id', '')} to {relay_url}")
time.sleep(1.0)
return True
except Exception as e:
logger.debug(f"Failed to publish to {relay_url}: {type(e).__name__}")
return False
finally:
try:
rm.close_connections()
except Exception:
pass
return await asyncio.to_thread(_sync_publish)
async def announce_provider() -> None:
"""
Background task to announce this Routstr provider to Nostr relays.
Checks for existing announcements and creates new ones if needed.
"""
# Check for NSEC in environment (use NSEC only)
nsec = settings.nsec
if not nsec:
logger.info("Nostr private key not found (NSEC), skipping NIP-91 announcement")
return
# Convert NSEC to keypair
keypair = nsec_to_keypair(nsec)
if not keypair:
logger.error("Failed to parse NSEC, skipping NIP-91 announcement")
return
private_key_hex, public_key_hex = keypair
logger.info(f"Using Nostr pubkey: {public_key_hex}")
# Resolve settings and determine if we can publish BEFORE touching relays
try:
base_url: str | None = settings.http_url
onion_url: str | None = settings.onion_url
provider_name = settings.name or "Routstr Proxy"
provider_about = settings.description or "Privacy-preserving AI proxy via Nostr"
cashu_mints = [m.strip() for m in settings.cashu_mints if m.strip()]
except Exception:
base_url = settings.http_url or None
onion_url = settings.onion_url or None
provider_name = settings.name or "Routstr Proxy"
provider_about = settings.description or "Privacy-preserving AI proxy via Nostr"
cashu_mints = [m.strip() for m in settings.cashu_mints if m.strip()]
if not onion_url:
discovered = discover_onion_url_from_tor()
if discovered:
onion_url = discovered
logger.info(f"Discovered onion URL via Tor volume: {onion_url}")
mint_urls = cashu_mints if cashu_mints else None
endpoint_urls: list[str] = []
if base_url and base_url.strip() and base_url.strip() != "http://localhost:8000":
endpoint_urls.append(base_url.strip())
if onion_url and onion_url.strip():
ou = onion_url.strip()
if ou.endswith(".onion") and not (
ou.startswith("http://") or ou.startswith("https://")
):
ou = f"http://{ou}"
endpoint_urls.append(ou)
if not endpoint_urls:
logger.warning(
"No valid endpoints configured (HTTP_URL/ONION_URL). Skipping NIP-91 publish."
)
return
# Only now configure relays and determine provider_id (may query relays)
relay_urls = [u.strip() for u in getattr(settings, "relays", []) if u.strip()]
if not relay_urls:
relay_urls = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://relay.routstr.com",
"wss://nos.lol",
]
provider_id = await _determine_provider_id(public_key_hex, relay_urls)
logger.info(f"Using provider_id: {provider_id}")
# Build metadata
metadata = {
"name": provider_name,
"about": provider_about,
}
# Create the candidate event that we would publish
version_str = get_app_version()
candidate_event = create_nip91_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
mint_urls=mint_urls,
version=version_str,
metadata=metadata,
)
# Backoff configuration and state (sensible defaults)
backoff_base = 5.0
backoff_max = 900.0
backoff_jitter_ratio = 0.2
relay_next_allowed: dict[str, float] = {}
relay_current_delay: dict[str, float] = {}
def _should_skip(relay: str) -> bool:
return time.time() < relay_next_allowed.get(relay, 0.0)
def _register_success(relay: str) -> None:
relay_current_delay[relay] = 0.0
relay_next_allowed[relay] = time.time()
def _register_failure(relay: str) -> None:
previous = relay_current_delay.get(relay, 0.0)
delay = backoff_base if previous <= 0.0 else min(backoff_max, previous * 2.0)
jitter = delay * backoff_jitter_ratio * (2.0 * random.random() - 1.0)
scheduled = time.time() + max(0.0, delay + jitter)
relay_current_delay[relay] = delay
relay_next_allowed[relay] = scheduled
logger.debug(
f"Backoff: {relay} delay={delay:.1f}s jitter={jitter:.1f}s next={int(scheduled)}"
)
# Fetch existing events for this provider_id
existing_events: list[dict[str, Any]] = []
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping {relay_url} due to backoff")
continue
events, ok = await query_nip91_events(relay_url, public_key_hex, provider_id)
if ok:
_register_success(relay_url)
existing_events.extend(events)
else:
_register_failure(relay_url)
# Decide whether to publish: publish if none exist or any differ from candidate
found_any = len(existing_events) > 0
all_match = found_any and all(
events_semantically_equal(ev, candidate_event) for ev in existing_events
)
if not all_match:
logger.debug(
"No matching NIP-91 announcement found or differences detected; publishing update"
)
success_count = 0
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping publish to {relay_url} due to backoff")
continue
if await publish_to_relay(relay_url, candidate_event):
_register_success(relay_url)
success_count += 1
else:
_register_failure(relay_url)
logger.info(
f"Published NIP-91 announcement to {success_count}/{len(relay_urls)} relays"
)
else:
logger.debug(
"Matching NIP-91 announcement already present; skipping publish on startup"
)
# Re-announce periodically (every 24 hours)
announcement_interval = 24 * 60 * 60
while True:
try:
await asyncio.sleep(announcement_interval)
# Build fresh candidate event for comparison
version_str = get_app_version()
candidate_event = create_nip91_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
mint_urls=mint_urls,
version=version_str,
metadata=metadata,
)
# Fetch existing events for this provider_id
existing_events = []
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping {relay_url} due to backoff")
continue
events, ok = await query_nip91_events(
relay_url, public_key_hex, provider_id
)
if ok:
_register_success(relay_url)
existing_events.extend(events)
else:
_register_failure(relay_url)
found_any = len(existing_events) > 0
all_match = found_any and all(
events_semantically_equal(ev, candidate_event) for ev in existing_events
)
if all_match:
logger.debug(
"Matching NIP-91 announcement already present; skipping periodic re-announce"
)
continue
logger.debug(
f"Re-announcing provider due to differences or absence: {candidate_event['id']}"
)
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping publish to {relay_url} due to backoff")
continue
ok = await publish_to_relay(relay_url, candidate_event)
if ok:
_register_success(relay_url)
else:
_register_failure(relay_url)
except asyncio.CancelledError:
logger.info("NIP-91 announcement task cancelled")
break
except Exception as e:
logger.debug(f"Error in NIP-91 announcement loop: {type(e).__name__}")
# Continue running despite errors
-237
View File
@@ -1,237 +0,0 @@
import math
from pydantic.v1 import BaseModel
from ..core import get_logger
from ..core.db import AsyncSession
from ..core.settings import settings
from .price import sats_usd_price
logger = get_logger(__name__)
class CostData(BaseModel):
base_msats: int
input_msats: int
output_msats: int
total_msats: int
total_usd: float = 0.0
class MaxCostData(CostData):
pass
class CostDataError(BaseModel):
message: str
code: str
async def calculate_cost( # todo: can be sync
response_data: dict, max_cost: int, session: AsyncSession
) -> CostData | MaxCostData | CostDataError:
"""
Calculate the cost of an API request based on token usage.
Args:
response_data: Response data containing usage information
max_cost: Maximum cost in millisats
Returns:
Cost data or error information
"""
logger.debug(
"Starting cost calculation",
extra={
"max_cost_msats": max_cost,
"has_usage_data": "usage" in response_data,
"response_model": response_data.get("model", "unknown"),
},
)
if "usage" not in response_data or response_data["usage"] is None:
logger.warning(
"No usage data in response, using base cost only",
extra={
"max_cost_msats": max_cost,
"model": response_data.get("model", "unknown"),
},
)
return MaxCostData(
base_msats=0,
input_msats=0,
output_msats=0,
total_msats=0,
total_usd=0.0,
)
usage_data = response_data["usage"]
usd_cost = 0.0
# Prioritize cost_details.upstream_inference_cost
if "cost_details" in usage_data:
usd_cost = float(
usage_data["cost_details"].get("upstream_inference_cost", 0) or 0
)
# Fallback to cost field if upstream_inference_cost is 0
if usd_cost == 0 and "cost" in usage_data:
try:
usd_cost = float(usage_data.get("cost", 0) or 0)
except Exception:
pass
if usd_cost > 0:
try:
sats_per_usd = 1.0 / sats_usd_price()
cost_in_sats = usd_cost * sats_per_usd
cost_in_msats = math.ceil(cost_in_sats * 1000)
logger.info(
"Using cost from usage data/details",
extra={
"usd_cost": usd_cost,
"cost_in_sats": cost_in_sats,
"cost_in_msats": cost_in_msats,
"model": response_data.get("model", "unknown"),
},
)
return CostData(
base_msats=-1,
input_msats=-1, # Cost field doesn't break down by token type
output_msats=-1,
total_msats=cost_in_msats,
total_usd=usd_cost,
)
except Exception as e:
logger.warning(
"Error calculating cost from usage data",
extra={
"error": str(e),
"usd_cost": usd_cost,
"model": response_data.get("model", "unknown"),
},
)
# Fall through to token-based calculation
MSATS_PER_1K_INPUT_TOKENS: float = (
float(settings.fixed_per_1k_input_tokens) * 1000.0
)
MSATS_PER_1K_OUTPUT_TOKENS: float = (
float(settings.fixed_per_1k_output_tokens) * 1000.0
)
if not settings.fixed_pricing:
response_model = response_data.get("model", "")
logger.debug(
"Using model-based pricing",
extra={"model": response_model},
)
from ..proxy import get_model_instance
model_obj = get_model_instance(response_model)
if not model_obj:
logger.error(
"Invalid model in response",
extra={"response_model": response_model},
)
return CostDataError(
message=f"Invalid model in response: {response_model}",
code="model_not_found",
)
if not model_obj.sats_pricing:
logger.error(
"Model pricing not defined",
extra={"model": response_model, "model_id": response_model},
)
return CostDataError(
message="Model pricing not defined", code="pricing_not_found"
)
try:
mspp = float(model_obj.sats_pricing.prompt)
mspc = float(model_obj.sats_pricing.completion)
except Exception:
return CostDataError(message="Invalid pricing data", code="pricing_invalid")
MSATS_PER_1K_INPUT_TOKENS = mspp * 1_000_000.0
MSATS_PER_1K_OUTPUT_TOKENS = mspc * 1_000_000.0
logger.info(
"Applied model-specific pricing",
extra={
"model": response_model,
"input_price_msats_per_1k": MSATS_PER_1K_INPUT_TOKENS,
"output_price_msats_per_1k": MSATS_PER_1K_OUTPUT_TOKENS,
},
)
if not (MSATS_PER_1K_OUTPUT_TOKENS and MSATS_PER_1K_INPUT_TOKENS):
logger.warning(
"No token pricing configured, using base cost",
extra={
"base_cost_msats": max_cost,
"model": response_data.get("model", "unknown"),
},
)
return MaxCostData(
base_msats=max_cost,
input_msats=0,
output_msats=0,
total_msats=max_cost,
)
input_tokens = usage_data.get("prompt_tokens", 0)
output_tokens = usage_data.get("completion_tokens", 0)
# added for response api
input_tokens = (
input_tokens if input_tokens != 0 else usage_data.get("input_tokens", 0)
)
output_tokens = (
output_tokens if output_tokens != 0 else usage_data.get("output_tokens", 0)
)
# added for response api
input_tokens = (
input_tokens
if input_tokens != 0
else response_data.get("usage", {}).get("input_tokens", 0)
)
output_tokens = (
output_tokens
if output_tokens != 0
else response_data.get("usage", {}).get("output_tokens", 0)
)
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
token_based_cost = math.ceil(input_msats + output_msats)
total_usd = (token_based_cost / 1000.0) * sats_usd_price()
logger.info(
"Calculated token-based cost",
extra={
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"input_cost_msats": input_msats,
"output_cost_msats": output_msats,
"total_cost_msats": token_based_cost,
"total_usd": total_usd,
"model": response_data.get("model", "unknown"),
},
)
return CostData(
base_msats=0,
input_msats=int(input_msats),
output_msats=int(output_msats),
total_msats=token_based_cost,
total_usd=total_usd,
)

Some files were not shown because too many files have changed in this diff Show More