Merge branch 'origin/v0.2.1' into cursor/recreate-branch-with-original-commits-gemini-3-pro-preview-bbe5

This commit is contained in:
Cursor Agent
2025-11-23 22:23:00 +00:00
14 changed files with 814 additions and 33 deletions
+1 -1
View File
@@ -162,7 +162,7 @@ Once built, the UI is automatically served by the FastAPI backend:
- **Dashboard**: `http://localhost:8000/` - **Dashboard**: `http://localhost:8000/`
- **Login**: `http://localhost:8000/login` - **Login**: `http://localhost:8000/login`
- **Models Management**: `http://localhost:8000/model - **Models Management**: `http://localhost:8000/model`
- **Providers Management**: `http://localhost:8000/providers` - **Providers Management**: `http://localhost:8000/providers`
- **Settings**: `http://localhost:8000/settings` - **Settings**: `http://localhost:8000/settings`
+1 -1
View File
@@ -9,7 +9,7 @@ from sqlmodel import col, update
from .core import get_logger from .core import get_logger
from .core.db import ApiKey, AsyncSession from .core.db import ApiKey, AsyncSession
from .core.settings import settings from .core.settings import settings
from .payment.cost_caculation import ( from .payment.cost_calculation import (
CostData, CostData,
CostDataError, CostDataError,
MaxCostData, MaxCostData,
+1 -2
View File
@@ -182,7 +182,6 @@ async def info() -> dict:
"mints": global_settings.cashu_mints, "mints": global_settings.cashu_mints,
"http_url": global_settings.http_url, "http_url": global_settings.http_url,
"onion_url": global_settings.onion_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(balance_router)
app.include_router(deprecated_wallet_router) app.include_router(deprecated_wallet_router)
app.include_router(providers_router) app.include_router(providers_router)
app.include_router(proxy_router) app.include_router(proxy_router)
+1 -1
View File
@@ -114,7 +114,7 @@ def resolve_bootstrap() -> Settings:
) )
except Exception: except Exception:
pass pass
# Map COST_PER_1K_* -> CUSTOM_PER_1K_* # Map COST_PER_1K_* -> FIXED_PER_1K_*
if ( if (
"COST_PER_1K_INPUT_TOKENS" in os.environ "COST_PER_1K_INPUT_TOKENS" in os.environ
and "FIXED_PER_1K_INPUT_TOKENS" not in os.environ and "FIXED_PER_1K_INPUT_TOKENS" not in os.environ
+1 -1
View File
@@ -1,4 +1,4 @@
from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost from .cost_calculation import CostData, CostDataError, MaxCostData, calculate_cost
__all__ = [ __all__ = [
"CostData", "CostData",
+11 -10
View File
@@ -2,6 +2,7 @@ import asyncio
import json import json
import random import random
from pathlib import Path from pathlib import Path
from typing import Final
from urllib.request import urlopen from urllib.request import urlopen
import httpx import httpx
@@ -19,6 +20,14 @@ logger = get_logger(__name__)
models_router = APIRouter() 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): class Architecture(BaseModel):
modality: str modality: str
@@ -89,11 +98,7 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
if ( if (
"(free)" in model.get("name", "") "(free)" in model.get("name", "")
or model_id == "openrouter/auto" or model_id in DEFAULT_EXCLUDED_MODEL_IDS
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"
): ):
continue continue
@@ -130,11 +135,7 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
if ( if (
"(free)" in model.get("name", "") "(free)" in model.get("name", "")
or model_id == "openrouter/auto" or model_id in DEFAULT_EXCLUDED_MODEL_IDS
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"
): ):
continue continue
+15 -2
View File
@@ -18,7 +18,7 @@ from ..core.db import ApiKey, AsyncSession, create_session
if TYPE_CHECKING: if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow from ..core.db import UpstreamProviderRow
from ..payment.cost_caculation import ( from ..payment.cost_calculation import (
CostData, CostData,
CostDataError, CostDataError,
MaxCostData, MaxCostData,
@@ -136,6 +136,14 @@ class BaseUpstreamProvider:
if headers.pop(auth_header, None) is not None: if headers.pop(auth_header, None) is not None:
removed_headers.append(auth_header) 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( logger.debug(
"Headers prepared for upstream", "Headers prepared for upstream",
extra={ extra={
@@ -504,11 +512,16 @@ class BaseUpstreamProvider:
) )
await finalize_without_usage() await finalize_without_usage()
raise 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( return StreamingResponse(
stream_with_cost(max_cost_for_model), stream_with_cost(max_cost_for_model),
status_code=response.status_code, status_code=response.status_code,
headers=dict(response.headers), headers=response_headers,
) )
async def handle_non_streaming_chat_completion( async def handle_non_streaming_chat_completion(
@@ -62,7 +62,6 @@ async def test_root_endpoint_structure_and_performance(
"mints", "mints",
"http_url", "http_url",
"onion_url", "onion_url",
"models",
] ]
for field in required_fields: for field in required_fields:
assert field in data, f"Missing required field: {field}" 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["mints"], list)
assert isinstance(data["http_url"], str) assert isinstance(data["http_url"], str)
assert isinstance(data["onion_url"], str) assert isinstance(data["onion_url"], str)
assert isinstance(data["models"], list)
# Validate models structure if any exist # Ensure models field is not present (removed as per issue #184)
for model in data["models"]: assert "models" not in data, "Models field should not be present in base URL output"
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}"
# Verify no database state changes # Verify no database state changes
diff = await db_snapshot.diff() diff = await db_snapshot.diff()
@@ -386,4 +379,4 @@ async def test_info_endpoints_response_consistency(
# Model IDs should be the same # Model IDs should be the same
first_ids = {m["id"] for m in first_models} first_ids = {m["id"] for m in first_models}
response_ids = {m["id"] for m in models} response_ids = {m["id"] for m in models}
assert first_ids == response_ids assert first_ids == response_ids
+30 -1
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { AppSidebar } from '@/components/app-sidebar'; import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header'; 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 { RefreshCw } from 'lucide-react';
import { useCurrencyStore } from '@/lib/stores/currency'; import { useCurrencyStore } from '@/lib/stores/currency';
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate'; 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() { export default function DashboardPage() {
const [timeRange, setTimeRange] = useState('24'); const [timeRange, setTimeRange] = useState('24');
const [interval, setInterval] = useState('15'); const [interval, setInterval] = useState('15');
const { displayUnit } = useCurrencyStore(); 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({ const { data: btcUsdPrice } = useQuery({
queryKey: ['btc-usd-price'], queryKey: ['btc-usd-price'],
@@ -83,6 +108,10 @@ export default function DashboardPage() {
staleTime: 30_000, staleTime: 30_000,
}); });
if (!isAuthenticated) {
return <CheatSheet />;
}
const handleRefresh = () => { const handleRefresh = () => {
refetchMetrics(); refetchMetrics();
refetchSummary(); refetchSummary();
+746
View File
@@ -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<NodeInfo> {
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<WalletSnapshot> {
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<WalletSnapshot | null>(null);
const [refundReceipt, setRefundReceipt] = useState<RefundReceipt | null>(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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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 (
<div className='min-h-screen bg-gradient-to-b from-background via-background to-muted'>
<main className='mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-10 sm:px-6 lg:px-8'>
<section className='relative space-y-3 text-center md:text-left'>
<div className='absolute right-0 top-0 hidden md:block'>
<Button asChild size='sm' className='px-3 text-xs'>
<a href='/login'>Admin</a>
</Button>
</div>
<div className='inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] uppercase tracking-wider text-muted-foreground'>
<Bolt className='h-4 w-4 text-primary' />
Routstr cheat sheet
</div>
<h1 className='text-3xl font-semibold tracking-tight sm:text-4xl'>
Node Identity and Cheat Sheet
</h1>
<div className='md:hidden'>
<Button asChild size='sm' className='w-full sm:w-auto'>
<a href='/login'>Admin</a>
</Button>
</div>
</section>
<section className='grid gap-4 lg:grid-cols-2'>
<Card>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div>
<CardTitle className='flex items-center gap-2 text-lg'>
<ShieldCheck className='h-4 w-4 text-primary' />
Node identity
</CardTitle>
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
/v1/info snapshot
</p>
</div>
<Button
variant='ghost'
size='sm'
className='gap-1 text-xs'
onClick={handleRefreshInfo}
disabled={isInfoLoading}
>
<RefreshCcw className='h-4 w-4' />
Refresh
</Button>
</CardHeader>
<CardContent className='space-y-4'>
{isInfoLoading && (
<p className='text-muted-foreground text-sm'>
Loading node profile…
</p>
)}
{isInfoError && !isInfoLoading && (
<p className='text-destructive text-sm'>
Unable to reach /v1/info at {normalizedBaseUrl}
</p>
)}
{nodeInfo && (
<>
<div className='space-y-2'>
<p className='text-2xl font-medium'>{nodeInfo.name}</p>
<p className='text-muted-foreground text-sm'>
{nodeInfo.description}
</p>
</div>
<dl className='grid gap-4 sm:grid-cols-2'>
<div>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
Version
</dt>
<dd className='text-base font-medium'>
{nodeInfo.version}
</dd>
</div>
<div>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
HTTP
</dt>
<dd className='text-base font-medium break-all'>
{nodeInfo.http_url || normalizedBaseUrl}
</dd>
</div>
{nodeInfo.onion_url && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
Onion
</dt>
<dd className='text-base font-medium break-all'>
{nodeInfo.onion_url}
</dd>
</div>
)}
{nodeInfo.npub && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
npub
</dt>
<dd className='flex items-center gap-2 break-all text-sm font-mono'>
{nodeInfo.npub}
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleCopy(nodeInfo.npub ?? '')}
>
<Copy className='h-4 w-4' />
</Button>
</dd>
</div>
)}
</dl>
<div className='space-y-2'>
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
Cashu mints
</p>
<div className='flex flex-wrap gap-2'>
{nodeInfo.mints.length ? (
nodeInfo.mints.map((mint) => (
<Badge
key={mint}
variant='secondary'
className='font-mono text-xs'
>
{mint}
</Badge>
))
) : (
<p className='text-muted-foreground text-sm'>
No mint list published
</p>
)}
</div>
</div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<CardTitle className='flex items-center gap-2 text-lg'>
<Terminal className='h-4 w-4 text-primary' />
Quick docs
</CardTitle>
<span className='text-xs uppercase tracking-wide text-muted-foreground'>
curl-ready
</span>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center gap-2'>
<Input
value={baseUrl}
placeholder={normalizedBaseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
className='text-sm'
/>
<Button
variant='outline'
size='icon'
className='h-9 w-9'
onClick={() => handleCopy(normalizedBaseUrl)}
>
<Copy className='h-4 w-4' />
</Button>
</div>
<div className='rounded-lg bg-muted p-4 font-mono text-sm leading-6'>
<pre className='whitespace-pre-wrap break-all'>{curlSnippet}</pre>
</div>
<div className='flex gap-2'>
<Button
variant='secondary'
className='gap-2'
onClick={() => handleCopy(curlSnippet)}
>
<Copy className='h-4 w-4' />
Copy curl
</Button>
<Button variant='outline' asChild className='gap-2'>
<a
href='https://docs.routstr.com'
target='_blank'
rel='noreferrer'
>
<Terminal className='h-4 w-4' />
Full docs
</a>
</Button>
</div>
</CardContent>
</Card>
</section>
<Card>
<CardHeader className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-xl'>
<KeyRound className='h-5 w-5 text-primary' />
API key workflow
</CardTitle>
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
Sections expand as soon as you interact
</p>
</CardHeader>
<CardContent className='space-y-6'>
<section className='space-y-2'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>1 · Create key</span>
{showCreateDetails && (
<span className='text-primary'>Cashu token detected</span>
)}
</header>
<Textarea
value={initialToken}
onChange={(event) => setInitialToken(event.target.value)}
placeholder='cashuA1...'
rows={showCreateDetails ? 4 : 2}
className='font-mono text-sm transition-all duration-200'
onFocus={() => setHasInteractedCreate(true)}
/>
{showCreateDetails && (
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleCreateKey}
disabled={isCreatingKey}
className='gap-2'
>
{isCreatingKey ? 'Creating…' : 'Create API key'}
</Button>
<span className='text-xs text-muted-foreground'>
Redeems instantly and returns <code>sk-</code> key.
</span>
</div>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>2 · Manage key</span>
{walletInfo && (
<span className='text-primary'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
)}
</header>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
value={apiKeyInput}
onChange={(event) => {
setApiKeyInput(event.target.value);
setWalletInfo(null);
}}
placeholder='sk-...'
className='font-mono text-sm'
onFocus={() => setHasInteractedManage(true)}
/>
<div className='flex gap-2'>
<Button
variant='outline'
size='icon'
className='h-10 w-10'
onClick={() => handleCopy(activeApiKey)}
disabled={!activeApiKey}
>
<Copy className='h-4 w-4' />
</Button>
<Button
variant='secondary'
size='sm'
className='gap-1'
onClick={handleSyncBalance}
disabled={isSyncingBalance || !activeApiKey}
>
<RefreshCcw className='h-4 w-4' />
Sync
</Button>
</div>
</div>
{showManageDetails && (
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<p className='text-[0.65rem] uppercase tracking-wide text-muted-foreground'>
Spendable
</p>
<p className='text-xl font-semibold'>
{walletInfo
? `${formatSats(walletInfo.balanceMsats)} sats`
: '—'}
</p>
{walletInfo && (
<p className='text-muted-foreground text-xs'>
{formatMsats(walletInfo.balanceMsats)} msats
</p>
)}
</div>
<div className='rounded-lg border p-3'>
<p className='text-[0.65rem] uppercase tracking-wide text-muted-foreground'>
Reserved
</p>
<p className='text-xl font-semibold'>
{walletInfo
? `${formatSats(walletInfo.reservedMsats)} sats`
: '—'}
</p>
{walletInfo && (
<p className='text-muted-foreground text-xs'>
{formatMsats(walletInfo.reservedMsats)} msats
</p>
)}
</div>
</div>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>3 · Top up</span>
{showTopupDetails && (
<span className={canTopup ? 'text-primary' : 'text-destructive'}>
{canTopup ? 'Ready to redeem' : 'Paste API key first'}
</span>
)}
</header>
<Textarea
value={topupToken}
onChange={(event) => setTopupToken(event.target.value)}
placeholder='cashuB1...'
rows={showTopupDetails ? 3 : 1}
className='font-mono text-sm transition-all duration-200'
onFocus={() => setHasInteractedTopup(true)}
disabled={!canTopup}
/>
{showTopupDetails && (
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleTopup}
disabled={isTopupLoading || !canTopup}
variant='outline'
className='gap-2'
>
{isTopupLoading ? 'Topping up…' : 'Top up this key'}
</Button>
<span className='text-xs text-muted-foreground'>
{canTopup
? (
<>
Adds balance to the same <code>sk-</code> token.
</>
)
: 'Enter your sk- key above to unlock top ups.'}
</span>
</div>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>4 · Refund</span>
{refundReceipt && <span className='text-primary'>Done</span>}
</header>
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleRefund}
disabled={isRefunding}
variant='destructive'
className='gap-2'
>
{isRefunding ? 'Processing…' : 'Refund remaining balance'}
</Button>
<span className='text-xs text-muted-foreground'>
Burns the key and returns a fresh Cashu token.
</span>
</div>
{refundToken && (
<div className='space-y-2 rounded-lg border bg-muted/30 p-4'>
<div className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>Cashu refund token</span>
<Button
variant='outline'
size='sm'
className='gap-1 text-xs'
onClick={() => handleCopy(refundToken)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
<Textarea
value={refundToken}
readOnly
rows={4}
className='font-mono text-xs'
/>
</div>
)}
</section>
</CardContent>
</Card>
</main>
</div>
);
}
+4 -2
View File
@@ -9,8 +9,10 @@ export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
useEffect(() => { useEffect(() => {
const publicPaths = ['/login', '/_register', '/unauthorized']; const publicPaths = ['/', '/login', '/_register', '/unauthorized'];
const isPublicPath = publicPaths.some((path) => pathname.startsWith(path)); const isPublicPath = publicPaths.some((path) =>
path === '/' ? pathname === '/' : pathname.startsWith(path)
);
if (!isPublicPath && !ConfigurationService.isTokenValid()) { if (!isPublicPath && !ConfigurationService.isTokenValid()) {
router.push('/login'); router.push('/login');
Vendored
-1
Submodule vendor/cdk deleted from 52d796e9fe
Submodule vendor/cdk-python deleted from 915c6966b0