diff --git a/README.md b/README.md index 2d482dc1..a0834551 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ Once built, the UI is automatically served by the FastAPI backend: - **Dashboard**: `http://localhost:8000/` - **Login**: `http://localhost:8000/login` -- **Models Management**: `http://localhost:8000/model +- **Models Management**: `http://localhost:8000/model` - **Providers Management**: `http://localhost:8000/providers` - **Settings**: `http://localhost:8000/settings` diff --git a/routstr/auth.py b/routstr/auth.py index b1e16987..d5b8ed87 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -9,7 +9,7 @@ from sqlmodel import col, update from .core import get_logger from .core.db import ApiKey, AsyncSession from .core.settings import settings -from .payment.cost_caculation import ( +from .payment.cost_calculation import ( CostData, CostDataError, MaxCostData, diff --git a/routstr/core/main.py b/routstr/core/main.py index 69e57992..5409856d 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -182,7 +182,6 @@ async def info() -> dict: "mints": global_settings.cashu_mints, "http_url": global_settings.http_url, "onion_url": global_settings.onion_url, - "models": [], # kept for back-compat; prefer /v1/models } @@ -335,4 +334,4 @@ 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) +app.include_router(proxy_router) \ No newline at end of file diff --git a/routstr/core/settings.py b/routstr/core/settings.py index cd59758c..302df24d 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -114,7 +114,7 @@ def resolve_bootstrap() -> Settings: ) except Exception: pass - # Map COST_PER_1K_* -> CUSTOM_PER_1K_* + # 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 diff --git a/routstr/payment/__init__.py b/routstr/payment/__init__.py index 55f5a854..0ca1ed03 100644 --- a/routstr/payment/__init__.py +++ b/routstr/payment/__init__.py @@ -1,4 +1,4 @@ -from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost +from .cost_calculation import CostData, CostDataError, MaxCostData, calculate_cost __all__ = [ "CostData", diff --git a/routstr/payment/cost_caculation.py b/routstr/payment/cost_calculation.py similarity index 100% rename from routstr/payment/cost_caculation.py rename to routstr/payment/cost_calculation.py diff --git a/routstr/payment/models.py b/routstr/payment/models.py index 849cf103..2e3fbd9c 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -2,6 +2,7 @@ import asyncio import json import random from pathlib import Path +from typing import Final from urllib.request import urlopen import httpx @@ -19,6 +20,14 @@ logger = get_logger(__name__) models_router = APIRouter() +DEFAULT_EXCLUDED_MODEL_IDS: Final[set[str]] = { + "openrouter/auto", + "google/gemini-2.5-pro-exp-03-25", + "opengvlab/internvl3-78b", + "openrouter/sonoma-dusk-alpha", + "openrouter/sonoma-sky-alpha", +} + class Architecture(BaseModel): modality: str @@ -89,11 +98,7 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]: if ( "(free)" in model.get("name", "") - or model_id == "openrouter/auto" - or model_id == "google/gemini-2.5-pro-exp-03-25" - or model_id == "opengvlab/internvl3-78b" - or model_id == "openrouter/sonoma-dusk-alpha" - or model_id == "openrouter/sonoma-sky-alpha" + or model_id in DEFAULT_EXCLUDED_MODEL_IDS ): continue @@ -130,11 +135,7 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis if ( "(free)" in model.get("name", "") - or model_id == "openrouter/auto" - or model_id == "google/gemini-2.5-pro-exp-03-25" - or model_id == "opengvlab/internvl3-78b" - or model_id == "openrouter/sonoma-dusk-alpha" - or model_id == "openrouter/sonoma-sky-alpha" + or model_id in DEFAULT_EXCLUDED_MODEL_IDS ): continue diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index aa994147..576ade2f 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -18,7 +18,7 @@ from ..core.db import ApiKey, AsyncSession, create_session if TYPE_CHECKING: from ..core.db import UpstreamProviderRow -from ..payment.cost_caculation import ( +from ..payment.cost_calculation import ( CostData, CostDataError, MaxCostData, @@ -136,6 +136,14 @@ class BaseUpstreamProvider: if headers.pop(auth_header, None) is not None: removed_headers.append(auth_header) + for header in ["authorization", "accept-encoding"]: + if headers.pop(header, None) is not None: + removed_headers.append(f"{header} (replaced with routstr-safe version)") + + # Explicitly define the list of supported compression encodings + headers["accept-encoding"] = "gzip, deflate, br, identity" + + logger.debug( "Headers prepared for upstream", extra={ @@ -504,11 +512,16 @@ class BaseUpstreamProvider: ) await finalize_without_usage() raise + + # Remove inaccurate encoding headers from upstream response + response_headers = dict(response.headers) + response_headers.pop("content-encoding", None) + response_headers.pop("content-length", None) return StreamingResponse( stream_with_cost(max_cost_for_model), status_code=response.status_code, - headers=dict(response.headers), + headers=response_headers, ) async def handle_non_streaming_chat_completion( diff --git a/tests/integration/test_general_info_endpoints.py b/tests/integration/test_general_info_endpoints.py index cdc8e652..41250f66 100644 --- a/tests/integration/test_general_info_endpoints.py +++ b/tests/integration/test_general_info_endpoints.py @@ -62,7 +62,6 @@ async def test_root_endpoint_structure_and_performance( "mints", "http_url", "onion_url", - "models", ] for field in required_fields: assert field in data, f"Missing required field: {field}" @@ -75,15 +74,9 @@ async def test_root_endpoint_structure_and_performance( assert isinstance(data["mints"], list) assert isinstance(data["http_url"], str) assert isinstance(data["onion_url"], str) - assert isinstance(data["models"], list) - # Validate models structure if any exist - for model in data["models"]: - assert isinstance(model, dict) - # Models should have at least basic fields - model_required_fields = ["id", "name"] - for field in model_required_fields: - assert field in model, f"Model missing required field: {field}" + # Ensure models field is not present (removed as per issue #184) + assert "models" not in data, "Models field should not be present in base URL output" # Verify no database state changes diff = await db_snapshot.diff() @@ -386,4 +379,4 @@ async def test_info_endpoints_response_consistency( # Model IDs should be the same first_ids = {m["id"] for m in first_models} response_ids = {m["id"] for m in models} - assert first_ids == response_ids + assert first_ids == response_ids \ No newline at end of file diff --git a/ui/app/page.tsx b/ui/app/page.tsx index 628e104d..682216b5 100644 --- a/ui/app/page.tsx +++ b/ui/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { AppSidebar } from '@/components/app-sidebar'; import { SiteHeader } from '@/components/site-header'; @@ -23,11 +23,36 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { RefreshCw } from 'lucide-react'; import { useCurrencyStore } from '@/lib/stores/currency'; import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate'; +import { CheatSheet } from '@/components/landing/cheat-sheet'; +import { ConfigurationService } from '@/lib/api/services/configuration'; export default function DashboardPage() { const [timeRange, setTimeRange] = useState('24'); const [interval, setInterval] = useState('15'); const { displayUnit } = useCurrencyStore(); + const [isAuthenticated, setIsAuthenticated] = useState(() => { + if (typeof window === 'undefined') { + return false; + } + return ConfigurationService.isTokenValid(); + }); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const syncAuthState = (): void => { + setIsAuthenticated(ConfigurationService.isTokenValid()); + }; + + syncAuthState(); + window.addEventListener('storage', syncAuthState); + + return () => { + window.removeEventListener('storage', syncAuthState); + }; + }, []); const { data: btcUsdPrice } = useQuery({ queryKey: ['btc-usd-price'], @@ -83,6 +108,10 @@ export default function DashboardPage() { staleTime: 30_000, }); + if (!isAuthenticated) { + return ; + } + const handleRefresh = () => { refetchMetrics(); refetchSummary(); diff --git a/ui/components/landing/cheat-sheet.tsx b/ui/components/landing/cheat-sheet.tsx new file mode 100644 index 00000000..e66897dc --- /dev/null +++ b/ui/components/landing/cheat-sheet.tsx @@ -0,0 +1,746 @@ +'use client'; + +import { type JSX, useCallback, useEffect, useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + Bolt, + Copy, + KeyRound, + RefreshCcw, + ShieldCheck, + Terminal, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { ConfigurationService } from '@/lib/api/services/configuration'; + +type NodeInfo = { + name: string; + description: string; + version: string; + npub?: string | null; + mints: string[]; + http_url?: string | null; + onion_url?: string | null; +}; + +type WalletSnapshot = { + apiKey: string; + balanceMsats: number; + reservedMsats: number; +}; + +type RefundReceipt = { + token?: string; + recipient?: string; + sats?: string; + msats?: string; +}; + +const DEFAULT_BASE_URL = 'http://127.0.0.1:8000'; + +async function fetchNodeInfo(baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/v1/info`, { + cache: 'no-store', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Unable to load node info'); + } + + const payload = (await response.json()) as NodeInfo; + return { + ...payload, + mints: Array.isArray(payload.mints) ? payload.mints : [], + }; +} + +async function fetchWalletInfo( + baseUrl: string, + apiKey: string +): Promise { + const response = await fetch(`${baseUrl}/v1/balance/info`, { + cache: 'no-store', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Unable to load wallet info'); + } + + const payload = (await response.json()) as { + api_key: string; + balance: number; + reserved?: number; + }; + + return { + apiKey: payload.api_key || apiKey, + balanceMsats: payload.balance ?? 0, + reservedMsats: payload.reserved ?? 0, + }; +} + +function normalizeBaseUrl(url: string): string { + const trimmed = url.trim(); + if (!trimmed) { + return ''; + } + return trimmed.replace(/\/+$/, ''); +} + +function formatMsats(msats: number): string { + return new Intl.NumberFormat('en-US').format(msats); +} + +function formatSats(msats: number): string { + return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000)); +} + +export function CheatSheet(): JSX.Element { + const [baseUrl, setBaseUrl] = useState(() => + typeof window === 'undefined' ? '' : ConfigurationService.getLocalBaseUrl() + ); + const [initialToken, setInitialToken] = useState(''); + const [topupToken, setTopupToken] = useState(''); + const [apiKeyInput, setApiKeyInput] = useState(''); + const [walletInfo, setWalletInfo] = useState(null); + const [refundReceipt, setRefundReceipt] = useState(null); + const [isCreatingKey, setIsCreatingKey] = useState(false); + const [isTopupLoading, setIsTopupLoading] = useState(false); + const [isRefunding, setIsRefunding] = useState(false); + const [isSyncingBalance, setIsSyncingBalance] = useState(false); + const [hasInteractedCreate, setHasInteractedCreate] = useState(false); + const [hasInteractedManage, setHasInteractedManage] = useState(false); + const [hasInteractedTopup, setHasInteractedTopup] = useState(false); + + useEffect(() => { + if (!baseUrl && typeof window !== 'undefined') { + setBaseUrl(ConfigurationService.getLocalBaseUrl()); + } + }, [baseUrl]); + + const normalizedBaseUrl = useMemo( + () => normalizeBaseUrl(baseUrl) || DEFAULT_BASE_URL, + [baseUrl] + ); + + const activeApiKey = apiKeyInput.trim(); + + const { + data: nodeInfo, + isLoading: isInfoLoading, + isError: isInfoError, + refetch: refetchNodeInfo, + } = useQuery({ + queryKey: ['node-info', normalizedBaseUrl], + queryFn: () => fetchNodeInfo(normalizedBaseUrl), + enabled: Boolean(normalizedBaseUrl), + refetchInterval: 300_000, + staleTime: 120_000, + }); + + const handleCopy = useCallback(async (value: string): Promise => { + if (!value) { + return; + } + if (typeof navigator === 'undefined' || !navigator.clipboard) { + toast.error('Clipboard API unavailable'); + return; + } + try { + await navigator.clipboard.writeText(value); + toast.success('Copied to clipboard'); + } catch (error) { + console.error(error); + toast.error('Unable to copy'); + } + }, []); + + const handleCreateKey = useCallback(async (): Promise => { + if (!initialToken.trim()) { + toast.error('Cashu token required'); + return; + } + + setIsCreatingKey(true); + setRefundReceipt(null); + + try { + const params = new URLSearchParams({ + initial_balance_token: initialToken.trim(), + }); + const response = await fetch( + `${normalizedBaseUrl}/v1/balance/create?${params.toString()}`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + } + ); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to create API key'); + } + const payload = (await response.json()) as { + api_key: string; + balance: number; + }; + const snapshot: WalletSnapshot = { + apiKey: payload.api_key, + balanceMsats: payload.balance ?? 0, + reservedMsats: 0, + }; + setApiKeyInput(snapshot.apiKey); + setWalletInfo(snapshot); + setInitialToken(''); + toast.success('API key ready'); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error ? error.message : 'Failed to create API key' + ); + } finally { + setIsCreatingKey(false); + } + }, [initialToken, normalizedBaseUrl]); + + const handleSyncBalance = useCallback(async (): Promise => { + if (!activeApiKey) { + toast.error('Paste an API key first'); + return; + } + + setIsSyncingBalance(true); + try { + const snapshot = await fetchWalletInfo(normalizedBaseUrl, activeApiKey); + setWalletInfo(snapshot); + toast.success('Balance synced'); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error ? error.message : 'Failed to sync balance' + ); + } finally { + setIsSyncingBalance(false); + } + }, [activeApiKey, normalizedBaseUrl]); + + const handleTopup = useCallback(async (): Promise => { + if (!activeApiKey) { + toast.error('Paste an API key first'); + return; + } + if (!topupToken.trim()) { + toast.error('Cashu token required for top-up'); + return; + } + + setIsTopupLoading(true); + setRefundReceipt(null); + try { + const response = await fetch(`${normalizedBaseUrl}/v1/balance/topup`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${activeApiKey}`, + }, + body: JSON.stringify({ cashu_token: topupToken.trim() }), + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to top up'); + } + const payload = (await response.json()) as { msats: number }; + toast.success(`Added ${formatSats(payload.msats)} sats`); + setTopupToken(''); + const snapshot = await fetchWalletInfo(normalizedBaseUrl, activeApiKey); + setWalletInfo(snapshot); + } catch (error) { + console.error(error); + toast.error(error instanceof Error ? error.message : 'Top-up failed'); + } finally { + setIsTopupLoading(false); + } + }, [activeApiKey, normalizedBaseUrl, topupToken]); + + const handleRefund = useCallback(async (): Promise => { + if (!activeApiKey) { + toast.error('Paste an API key first'); + return; + } + + setIsRefunding(true); + try { + const response = await fetch(`${normalizedBaseUrl}/v1/balance/refund`, { + method: 'POST', + headers: { + Authorization: `Bearer ${activeApiKey}`, + }, + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Refund failed'); + } + const payload = (await response.json()) as RefundReceipt; + setRefundReceipt(payload); + setWalletInfo(null); + toast.success('Refund requested'); + } catch (error) { + console.error(error); + toast.error(error instanceof Error ? error.message : 'Refund failed'); + } finally { + setIsRefunding(false); + } + }, [activeApiKey, normalizedBaseUrl]); + + const handleRefreshInfo = useCallback(async (): Promise => { + const result = await refetchNodeInfo(); + if (result.error) { + toast.error('Unable to refresh node info'); + } else { + toast.success('Node info refreshed'); + } + }, [refetchNodeInfo]); + + const curlSnippet = useMemo(() => { + const keyPreview = activeApiKey || 'YOUR_API_KEY'; + return [ + `curl -X POST "${normalizedBaseUrl}/v1/chat/completions"`, + ` -H "Authorization: Bearer ${keyPreview}"`, + ' -H "Content-Type: application/json"', + " -d '{", + ' "model": "openai/gpt-4o-mini",', + ' "messages": [', + ' {"role":"system","content":"You are Routstr."},', + ' {"role":"user","content":"Ping the node"}', + ' ]', + " }'", + ].join('\n'); + }, [activeApiKey, normalizedBaseUrl]); + + const showCreateDetails = + hasInteractedCreate || initialToken.trim().length > 0; + const showManageDetails = hasInteractedManage || Boolean(walletInfo); + const showTopupDetails = + hasInteractedTopup || topupToken.trim().length > 0; + const refundToken = refundReceipt?.token ?? null; + const canTopup = Boolean(activeApiKey); + + return ( +
+
+
+
+ +
+
+ + Routstr cheat sheet +
+

+ Node Identity and Cheat Sheet +

+
+ +
+
+ +
+ + +
+ + + Node identity + +

+ /v1/info snapshot +

+
+ +
+ + {isInfoLoading && ( +

+ Loading node profile… +

+ )} + {isInfoError && !isInfoLoading && ( +

+ Unable to reach /v1/info at {normalizedBaseUrl} +

+ )} + {nodeInfo && ( + <> +
+

{nodeInfo.name}

+

+ {nodeInfo.description} +

+
+
+
+
+ Version +
+
+ {nodeInfo.version} +
+
+
+
+ HTTP +
+
+ {nodeInfo.http_url || normalizedBaseUrl} +
+
+ {nodeInfo.onion_url && ( +
+
+ Onion +
+
+ {nodeInfo.onion_url} +
+
+ )} + {nodeInfo.npub && ( +
+
+ npub +
+
+ {nodeInfo.npub} + +
+
+ )} +
+
+

+ Cashu mints +

+
+ {nodeInfo.mints.length ? ( + nodeInfo.mints.map((mint) => ( + + {mint} + + )) + ) : ( +

+ No mint list published +

+ )} +
+
+ + )} +
+
+ + + + + + Quick docs + + + curl-ready + + + +
+ setBaseUrl(event.target.value)} + className='text-sm' + /> + +
+
+
{curlSnippet}
+
+
+ + +
+
+
+
+ + + + + + API key workflow + +

+ Sections expand as soon as you interact +

+
+ +
+
+ 1 · Create key + {showCreateDetails && ( + Cashu token detected + )} +
+