diff --git a/Dockerfile.full b/Dockerfile.full new file mode 100644 index 00000000..923b6e8f --- /dev/null +++ b/Dockerfile.full @@ -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"] diff --git a/docs/provider/deployment.md b/docs/provider/deployment.md index 4ad21e78..99f0166a 100644 --- a/docs/provider/deployment.md +++ b/docs/provider/deployment.md @@ -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 . ``` diff --git a/docs/provider/quickstart.md b/docs/provider/quickstart.md index 2771c1f3..8aabc5d9 100644 --- a/docs/provider/quickstart.md +++ b/docs/provider/quickstart.md @@ -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 diff --git a/routstr/payment/helpers.py b/routstr/payment/helpers.py index f1027bd6..4aea50c8 100644 --- a/routstr/payment/helpers.py +++ b/routstr/payment/helpers.py @@ -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"): diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index aabb12ca..26729f8f 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -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( diff --git a/tests/integration/test_child_keys_api.py b/tests/integration/test_child_keys_api.py index 1f01c0ab..7bfaf2a8 100644 --- a/tests/integration/test_child_keys_api.py +++ b/tests/integration/test_child_keys_api.py @@ -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 diff --git a/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx index e37791e2..97ddae80 100644 --- a/ui/components/child-key-creator.tsx +++ b/ui/components/child-key-creator.tsx @@ -251,7 +251,7 @@ export function ChildKeyCreator({ )}
- {configs.map((config, index) => ( + {configs.map((config) => (
- {nodeInfo.mints.length ? ( + {nodeInfo.mints?.length ? ( nodeInfo.mints.map((mint) => ( { - if (apiKey !== apiKeyInput) { - setApiKeyInput(apiKey); - } + setApiKeyInput(apiKey); }, [apiKey]); const fetchDetails = useCallback( diff --git a/ui/lib/api/services/auth.ts b/ui/lib/api/services/auth.ts index ba77ef2b..ad9a7724 100644 --- a/ui/lib/api/services/auth.ts +++ b/ui/lib/api/services/auth.ts @@ -1,5 +1,4 @@ import { z } from 'zod'; -import { apiClient } from '../client'; import { ConfigurationService } from './configuration'; import axios from 'axios';