mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
Merge branch 'v0.4.0' into add-routstr-provider
# Conflicts: # tests/integration/test_child_keys_api.py # ui/components/landing/api-key-manager.tsx # ui/components/landing/cashu-payment-workflow.tsx # ui/components/landing/cheat-sheet.tsx # ui/components/landing/key-info-details.tsx
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# Multi-stage Dockerfile for Routstr (includes UI build)
|
||||
# Stage 1: Build the UI
|
||||
FROM node:23-alpine AS ui-builder
|
||||
WORKDIR /app/ui
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy UI source
|
||||
COPY ui/package.json ui/pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY ui/ ./
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Next.js build produces a static export in 'out' directory
|
||||
RUN pnpm run build
|
||||
|
||||
# Stage 2: Build the Routstr Node
|
||||
FROM ghcr.io/astral-sh/uv:python3.11-alpine AS runner
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
pkgconf \
|
||||
build-base \
|
||||
automake \
|
||||
autoconf \
|
||||
libtool \
|
||||
m4 \
|
||||
perl \
|
||||
git
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the rest of the application (required for uv sync to find the package)
|
||||
COPY . .
|
||||
|
||||
# Install dependencies including the specific secp256k1 branch
|
||||
RUN uv add git+https://github.com/saschanaz/secp256k1-py.git#branch=upgrade060
|
||||
RUN uv sync --no-dev
|
||||
|
||||
# Copy the built UI from the ui-builder stage
|
||||
COPY --from=ui-builder /app/ui/out ./ui_out
|
||||
|
||||
ENV PORT=8000
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
CMD ["/app/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"]
|
||||
+26
-19
@@ -6,30 +6,25 @@ Production deployment guide for Routstr Provider nodes.
|
||||
|
||||
For production, use Docker Compose with persistent storage and optional Tor support.
|
||||
|
||||
### Basic Setup
|
||||
### Unified Setup (All-in-one)
|
||||
To build and run the node with the UI integrated in a single container using the multi-stage build:
|
||||
|
||||
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
|
||||
```bash
|
||||
docker build -f Dockerfile.full -t routstr-full .
|
||||
docker run -d -p 8000:8000 --env-file .env routstr-full
|
||||
```
|
||||
|
||||
Start the node:
|
||||
### Advanced Setup (Separated UI & Node)
|
||||
Use the included `compose.yml` for a more flexible setup that separates the UI build process from the node execution. This is useful for development or when you want to manage Tor as a separate service.
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Then configure everything via the [Admin Dashboard](http://localhost:8000/admin/).
|
||||
This will:
|
||||
1. **Build the UI**: Compiles the frontend and copies it to a shared volume.
|
||||
2. **Start Routstr**: Runs the Python node, mounting the built UI.
|
||||
3. **Start Tor**: Provides anonymous access via a `.onion` address.
|
||||
|
||||
---
|
||||
|
||||
@@ -189,8 +184,20 @@ docker compose up -d
|
||||
|
||||
## Building from Source
|
||||
|
||||
### Unified Image (UI + Node)
|
||||
The easiest way to build everything from source into a single production-ready image:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/routstr/routstr-core.git
|
||||
cd routstr-core
|
||||
docker build -t routstr-local .
|
||||
docker build -f Dockerfile.full -t routstr-full .
|
||||
```
|
||||
|
||||
### Individual Components
|
||||
If you prefer building them separately or using Docker Compose:
|
||||
|
||||
```bash
|
||||
# Build using compose
|
||||
docker compose build
|
||||
|
||||
# Or build the node only (requires manual UI build first)
|
||||
docker build -t routstr-node .
|
||||
```
|
||||
|
||||
@@ -26,6 +26,8 @@ You bring the API keys, Routstr handles the billing, payments, and client manage
|
||||
|
||||
## 1. Start the Node
|
||||
|
||||
You can run the pre-built image directly:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name routstr \
|
||||
@@ -34,6 +36,16 @@ docker run -d \
|
||||
ghcr.io/routstr/proxy:latest
|
||||
```
|
||||
|
||||
### Build from Source (Recommended)
|
||||
If you want to build the node and UI yourself from source, use the unified Dockerfile:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/routstr/routstr-core.git
|
||||
cd routstr-core
|
||||
docker build -f Dockerfile.full -t routstr-local .
|
||||
docker run -d -p 8000:8000 --name routstr routstr-local
|
||||
```
|
||||
|
||||
Verify it's running:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -169,9 +169,32 @@ async def calculate_discounted_max_cost(
|
||||
|
||||
tol = settings.tolerance_percentage
|
||||
tol_factor = max(0.0, 1 - float(tol) / 100.0)
|
||||
|
||||
max_prompt_allowed_sats = model_pricing.max_prompt_cost * tol_factor
|
||||
max_completion_allowed_sats = model_pricing.max_completion_cost * tol_factor
|
||||
|
||||
if model_obj:
|
||||
prompt_token_limit: int | None = None
|
||||
if model_obj.top_provider and (
|
||||
model_obj.top_provider.context_length
|
||||
or model_obj.top_provider.max_completion_tokens
|
||||
):
|
||||
cl = model_obj.top_provider.context_length
|
||||
mct = model_obj.top_provider.max_completion_tokens
|
||||
if cl and mct:
|
||||
prompt_token_limit = max(0, cl - mct)
|
||||
elif cl:
|
||||
prompt_token_limit = cl
|
||||
elif mct:
|
||||
prompt_token_limit = 0
|
||||
elif model_obj.context_length:
|
||||
prompt_token_limit = model_obj.context_length
|
||||
|
||||
if prompt_token_limit is not None:
|
||||
max_prompt_allowed_sats = (
|
||||
prompt_token_limit * model_pricing.prompt * tol_factor
|
||||
)
|
||||
|
||||
adjusted = max_cost_for_model
|
||||
|
||||
if messages := body.get("messages"):
|
||||
|
||||
+41
-16
@@ -5,21 +5,18 @@ import json
|
||||
import re
|
||||
import traceback
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING, Mapping
|
||||
from typing import Mapping
|
||||
|
||||
import httpx
|
||||
from fastapi import BackgroundTasks, HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
|
||||
from ..auth import adjust_payment_for_tokens, revert_pay_for_request
|
||||
from ..core import get_logger
|
||||
from ..core.db import ApiKey, AsyncSession, create_session
|
||||
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow, create_session
|
||||
from ..core.exceptions import UpstreamError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
from ..payment.cost_calculation import (
|
||||
CostData,
|
||||
CostDataError,
|
||||
@@ -32,6 +29,7 @@ from ..payment.models import (
|
||||
Pricing,
|
||||
_calculate_usd_max_costs,
|
||||
_update_model_sats_pricing,
|
||||
list_models,
|
||||
)
|
||||
from ..payment.price import sats_usd_price
|
||||
from ..wallet import recieve_token, send_token
|
||||
@@ -3023,18 +3021,45 @@ class BaseUpstreamProvider:
|
||||
async def refresh_models_cache(self) -> None:
|
||||
"""Refresh the in-memory models cache from upstream API."""
|
||||
try:
|
||||
models = await self.fetch_models()
|
||||
models_with_fees = [self._apply_provider_fee_to_model(m) for m in models]
|
||||
async with create_session() as session:
|
||||
stmt = select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == self.base_url,
|
||||
UpstreamProviderRow.api_key == self.api_key
|
||||
)
|
||||
result = await session.exec(stmt)
|
||||
|
||||
# .first() returns the object or None if not found
|
||||
provider = result.first()
|
||||
if not provider or not provider.id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
try:
|
||||
sats_to_usd = sats_usd_price()
|
||||
self._models_cache = [
|
||||
_update_model_sats_pricing(m, sats_to_usd) for m in models_with_fees
|
||||
]
|
||||
except Exception:
|
||||
self._models_cache = models_with_fees
|
||||
db_models = await list_models(
|
||||
session=session,
|
||||
upstream_id=provider.id,
|
||||
include_disabled=False,
|
||||
apply_fees=False,
|
||||
)
|
||||
db_model_ids: set[str] = {model.id for model in db_models}
|
||||
models = await self.fetch_models()
|
||||
model_ids = [model.id for model in models]
|
||||
diff = set(db_model_ids) - set(model_ids)
|
||||
|
||||
self._models_by_id = {m.id: m for m in self._models_cache}
|
||||
for db_model_id in diff:
|
||||
found_db_model = next((model_obj for model_obj in db_models if model_obj.id == db_model_id))
|
||||
models.append(found_db_model)
|
||||
|
||||
models_with_fees = [self._apply_provider_fee_to_model(m) for m in models]
|
||||
print([mode.id for mode in models_with_fees])
|
||||
|
||||
try:
|
||||
sats_to_usd = sats_usd_price()
|
||||
self._models_cache = [
|
||||
_update_model_sats_pricing(m, sats_to_usd) for m in models_with_fees
|
||||
]
|
||||
except Exception:
|
||||
self._models_cache = models_with_fees
|
||||
|
||||
self._models_by_id = {m.id: m for m in self._models_cache}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from typing import Any
|
||||
from sqlmodel import select
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -18,7 +17,7 @@ async def test_wallet_info_returns_child_keys(
|
||||
response = await authenticated_client.get("/v1/wallet/info")
|
||||
assert response.status_code == 200
|
||||
parent_data = response.json()
|
||||
parent_api_key = parent_data["api_key"]
|
||||
parent_data["api_key"]
|
||||
|
||||
# 2. Create child keys for this parent
|
||||
# We need to use the parent's authentication for this
|
||||
|
||||
@@ -251,7 +251,7 @@ export function ChildKeyCreator({
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-6'>
|
||||
{configs.map((config, index) => (
|
||||
{configs.map((config) => (
|
||||
<div
|
||||
key={config.id}
|
||||
className='bg-muted/30 relative space-y-4 rounded-lg border p-4 pt-6'
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import type { WalletSnapshot, ChildKeyInfo } from './key-info-details';
|
||||
import type { RefundReceipt } from './cashu-payment-workflow';
|
||||
|
||||
interface ApiKeyManagerProps {
|
||||
baseUrl: string;
|
||||
|
||||
@@ -9,9 +9,9 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { KeyOptions } from '@/components/key-options';
|
||||
import type { WalletSnapshot } from './key-info-details';
|
||||
import type { ChildKeyInfo, WalletSnapshot } from './key-info-details';
|
||||
|
||||
type RefundReceipt = {
|
||||
export type RefundReceipt = {
|
||||
token?: string;
|
||||
recipient?: string;
|
||||
sats?: string;
|
||||
@@ -56,7 +56,7 @@ async function fetchWalletInfo(
|
||||
balance_limit: number | null;
|
||||
balance_limit_reset: string | null;
|
||||
validity_date: number | null;
|
||||
child_keys?: any[];
|
||||
child_keys?: ChildKeyInfo[];
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -11,17 +11,24 @@ import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ConfigurationService } from '@/lib/api/services/configuration';
|
||||
import { CashuPaymentWorkflow } from './cashu-payment-workflow';
|
||||
import {
|
||||
CashuPaymentWorkflow,
|
||||
type RefundReceipt,
|
||||
} from './cashu-payment-workflow';
|
||||
import { LightningPaymentWorkflow } from './lightning-payment-workflow';
|
||||
import { ApiKeyManager } from './api-key-manager';
|
||||
import {
|
||||
KeyInfoDetails,
|
||||
type WalletSnapshot,
|
||||
type ChildKeyInfo,
|
||||
} from './key-info-details';
|
||||
import { KeyInfoDetails, type WalletSnapshot } from './key-info-details';
|
||||
import { ChildKeyCreator } from '@/components/child-key-creator';
|
||||
|
||||
type NodeInfo = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
http_url?: string;
|
||||
onion_url?: string;
|
||||
npub?: string;
|
||||
mints?: string[];
|
||||
child_key_cost_msats?: number;
|
||||
token?: string;
|
||||
recipient?: string;
|
||||
sats?: string;
|
||||
@@ -273,7 +280,7 @@ export function CheatSheet(): JSX.Element {
|
||||
Cashu mints
|
||||
</p>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{nodeInfo.mints.length ? (
|
||||
{nodeInfo.mints?.length ? (
|
||||
nodeInfo.mints.map((mint) => (
|
||||
<Badge
|
||||
key={mint}
|
||||
|
||||
@@ -67,9 +67,7 @@ export function KeyInfoDetails({
|
||||
|
||||
// Sync internal state with props if they change
|
||||
useEffect(() => {
|
||||
if (apiKey !== apiKeyInput) {
|
||||
setApiKeyInput(apiKey);
|
||||
}
|
||||
setApiKeyInput(apiKey);
|
||||
}, [apiKey]);
|
||||
|
||||
const fetchDetails = useCallback(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import { apiClient } from '../client';
|
||||
import { ConfigurationService } from './configuration';
|
||||
import axios from 'axios';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user