Compare commits

..
Author SHA1 Message Date
9qeklajc 1ebb7d71e1 fix test 2026-01-27 08:58:46 +01:00
9qeklajc bbf1e65a5d clean up & add docs 2026-01-26 20:44:23 +01:00
9qeklajc 42258ae39c fmt 2026-01-25 14:38:52 +01:00
9qeklajc 04d6903369 lint & fmt 2026-01-25 14:31:55 +01:00
9qeklajc b0b2ceb1a0 create child keys within the dashboard 2026-01-25 12:24:02 +01:00
shroominicandGitHub 9dfa58d69f Merge pull request #319 from Routstr/opencode-integ
Opencode integ
2026-01-24 14:40:56 +08:00
9qeklajc 22ec9c3132 fmt 2026-01-23 23:30:42 +01:00
9qeklajc b72c578954 Merge branch 'v0.3.0' into opencode-integ 2026-01-23 21:44:16 +01:00
shroominicandGitHub 7e299180fe Merge pull request #296 from Routstr/provider-fallback
Multi-Provider Fallback on UpstreamError
2026-01-23 09:50:04 +08:00
Shroominic f290534df4 bump v0.3.0 2026-01-23 09:49:49 +08:00
shroominicand9qeklajc 87fbb48ca8 routstr v0.2.2 - fixfix
v0.2.2 - release summary
--------------------------
#292 - Fix not enough inputs to melt
#295 - Update UI dependencies
#291 - Fix reserved balance
#289 - Better filtering options (#282)
#284 - Do not charge for empty content (#274)
#298 - Fix Provider Balance display in dashboard
#299 - fix refunds not accounting reserved balance
#300 - reset reserved balance on startup (optional)
#303 - ignore disabled provider
#301 - optimize price fetching
2026-01-22 13:03:36 +01:00
11 changed files with 640 additions and 310 deletions
+36
View File
@@ -434,6 +434,42 @@ Authorization: Bearer sk-...
}
```
### 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
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "routstr"
version = "0.2.2"
version = "0.3.0"
description = "Payment proxy for your LLM endpoint using cashu and nostr."
readme = "README.md"
requires-python = ">=3.11"
+40 -18
View File
@@ -251,12 +251,24 @@ async def donate(token: str, ref: str | None = None) -> str:
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 a child API key that uses the parent's balance."""
"""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(
@@ -264,38 +276,48 @@ async def create_child_key(
detail="Cannot create a child key for another child key.",
)
cost = settings.child_key_cost
cost_per_key = settings.child_key_cost
total_cost = cost_per_key * count
if key.total_balance < cost:
if key.total_balance < total_cost:
raise HTTPException(
status_code=402,
detail=f"Insufficient balance to create child key. {cost} mSats required.",
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
)
# Deduct cost from parent
key.balance -= cost
key.total_spent += cost
key.balance -= total_cost
key.total_spent += total_cost
session.add(key)
# Generate new key
# Generate new keys
import secrets
new_key_raw = secrets.token_hex(32)
new_key_hash = new_key_raw # We use the raw key as the hash for sk- keys
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)
child_key = ApiKey(
hashed_key=new_key_hash,
balance=0,
parent_key_hash=key.hashed_key,
)
session.add(child_key)
await session.commit()
return {
"api_key": "sk-" + new_key_hash,
"cost_msats": cost,
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(
+3 -2
View File
@@ -30,9 +30,9 @@ setup_logging()
logger = get_logger(__name__)
if os.getenv("VERSION_SUFFIX") is not None:
__version__ = f"0.2.2-{os.getenv('VERSION_SUFFIX')}"
__version__ = f"0.3.0-{os.getenv('VERSION_SUFFIX')}"
else:
__version__ = "0.2.2"
__version__ = "0.3.0"
@asynccontextmanager
@@ -188,6 +188,7 @@ async def info() -> dict:
"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,
}
+6
View File
@@ -15,6 +15,7 @@ class CostData(BaseModel):
input_msats: int
output_msats: int
total_msats: int
total_usd: float = 0.0
class MaxCostData(CostData):
@@ -61,6 +62,7 @@ async def calculate_cost( # todo: can be sync
input_msats=0,
output_msats=0,
total_msats=0,
total_usd=0.0,
)
usage_data = response_data["usage"]
@@ -101,6 +103,7 @@ async def calculate_cost( # todo: can be sync
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(
@@ -210,6 +213,7 @@ async def calculate_cost( # todo: can be sync
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",
@@ -219,6 +223,7 @@ async def calculate_cost( # todo: can be sync
"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"),
},
)
@@ -228,4 +233,5 @@ async def calculate_cost( # todo: can be sync
input_msats=int(input_msats),
output_msats=int(output_msats),
total_msats=token_based_cost,
total_usd=total_usd,
)
+190 -270
View File
@@ -451,164 +451,111 @@ class BaseUpstreamProvider:
async def stream_with_cost(
max_cost_for_model: int,
) -> AsyncGenerator[bytes, None]:
stored_chunks: list[bytes] = []
usage_finalized: bool = False
last_model_seen: str | None = None
usage_chunk_data: dict | None = None
done_seen: bool = False
async def finalize_without_usage() -> bytes | None:
async def finalize_db_only() -> None:
nonlocal usage_finalized
if usage_finalized:
return None
return
async with create_session() as new_session:
fresh_key = await new_session.get(key.__class__, key.hashed_key)
if not fresh_key:
logger.warning(
"Key not found when finalizing streaming payment",
extra={"key_hash": key.hashed_key[:8] + "..."},
)
usage_finalized = True
return None
return
try:
fallback: dict = {
"model": last_model_seen or "unknown",
"usage": None,
}
cost_data = await adjust_payment_for_tokens(
fresh_key, fallback, new_session, max_cost_for_model
await adjust_payment_for_tokens(
fresh_key,
{"model": last_model_seen or "unknown", "usage": None},
new_session,
max_cost_for_model,
)
usage_finalized = True
logger.info(
"Finalized streaming payment without explicit usage",
extra={
"key_hash": key.hashed_key[:8] + "...",
"cost_data": cost_data,
"balance_after_adjustment": fresh_key.balance,
},
)
return f"data: {json.dumps({'cost': cost_data})}\n\n".encode()
except Exception as cost_error:
logger.error(
"Error finalizing payment without usage",
extra={
"error": str(cost_error),
"error_type": type(cost_error).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
usage_finalized = True
return None
try:
async for chunk in response.aiter_bytes():
stored_chunks.append(chunk)
try:
for part in re.split(b"data: ", chunk):
if not part or part.strip() in (b"[DONE]", b""):
continue
try:
obj = json.loads(part)
if isinstance(obj, dict) and obj.get("model"):
last_model_seen = str(obj.get("model"))
except json.JSONDecodeError:
pass
except Exception:
pass
yield chunk
try:
async for chunk in response.aiter_bytes():
# Split chunk into SSE events
parts = re.split(b"data: ", chunk)
for i, part in enumerate(parts):
if not part:
continue
logger.debug(
"Streaming completed, analyzing usage data",
extra={
"key_hash": key.hashed_key[:8] + "...",
"chunks_count": len(stored_chunks),
},
)
stripped_part = part.strip()
if not stripped_part:
continue
for i in range(len(stored_chunks) - 1, -1, -1):
chunk = stored_chunks[i]
if not chunk:
continue
try:
events = re.split(b"data: ", chunk)
for event_data in events:
if not event_data or event_data.strip() in (b"[DONE]", b""):
continue
try:
data = json.loads(event_data)
if isinstance(data, dict) and data.get("model"):
last_model_seen = str(data.get("model"))
if isinstance(data, dict) and isinstance(
data.get("usage"), dict
):
async with create_session() as 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,
)
)
usage_finalized = True
logger.info(
"Payment adjustment completed for streaming",
extra={
"key_hash": key.hashed_key[:8]
+ "...",
"cost_data": cost_data,
"model": last_model_seen,
"balance_after_adjustment": fresh_key.balance,
},
)
yield f"data: {json.dumps({'cost': cost_data})}\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] + "...",
},
if stripped_part == b"[DONE]":
done_seen = True
continue
try:
obj = json.loads(part)
if isinstance(obj, dict):
if obj.get("model"):
last_model_seen = str(obj.get("model"))
if isinstance(obj.get("usage"), dict):
# Hold this chunk back to merge cost later
usage_chunk_data = obj
continue
except json.JSONDecodeError:
pass
prefix = (
b"data: " if (i > 0 or chunk.startswith(b"data: ")) else b""
)
yield prefix + part
# Stream finished, process usage if found
if usage_chunk_data:
async with create_session() as session:
fresh_key = await session.get(key.__class__, key.hashed_key)
if fresh_key:
try:
cost_data = await adjust_payment_for_tokens(
fresh_key,
usage_chunk_data,
session,
max_cost_for_model,
)
# Merge cost into usage
usage_chunk_data["usage"]["cost"] = cost_data.get(
"total_usd", 0.0
)
# Keep detailed cost in metadata
usage_chunk_data["metadata"] = usage_chunk_data.get(
"metadata", {}
)
usage_chunk_data["metadata"]["routstr"] = {
"cost": cost_data
}
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
usage_finalized = True
except Exception:
# Fallback: yield original usage chunk if adjustment fails
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
if not usage_finalized:
maybe_cost_event = await finalize_without_usage()
if maybe_cost_event is not None:
yield maybe_cost_event
await finalize_db_only()
if done_seen:
yield b"data: [DONE]\n\n"
except Exception as stream_error:
logger.warning(
"Streaming interrupted; finalizing without usage",
"Streaming interrupted; finalizing in background",
extra={
"error": str(stream_error),
"error_type": type(stream_error).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
finally:
if not usage_finalized:
await finalize_without_usage()
await finalize_db_only()
# Remove inaccurate encoding headers from upstream response
response_headers = dict(response.headers)
@@ -648,6 +595,7 @@ class BaseUpstreamProvider:
},
)
content: bytes | None = None
try:
content = await response.aread()
response_json = json.loads(content)
@@ -664,6 +612,14 @@ class BaseUpstreamProvider:
cost_data = await adjust_payment_for_tokens(
key, response_json, session, deducted_max_cost
)
# Merge cost into usage for OpenCode
if "usage" in response_json:
response_json["usage"]["cost"] = cost_data.get("total_usd", 0.0)
# Keep detailed cost
response_json["metadata"] = response_json.get("metadata", {})
response_json["metadata"]["routstr"] = {"cost": cost_data}
response_json["cost"] = cost_data
logger.info(
@@ -749,180 +705,135 @@ class BaseUpstreamProvider:
async def stream_with_responses_cost(
max_cost_for_model: int,
) -> AsyncGenerator[bytes, None]:
stored_chunks: list[bytes] = []
usage_finalized: bool = False
last_model_seen: str | None = None
reasoning_tokens: int = 0
usage_chunk_data: dict | None = None
done_seen: bool = False
async def finalize_without_usage() -> bytes | None:
async def finalize_db_only() -> None:
nonlocal usage_finalized
if usage_finalized:
return None
return
async with create_session() as new_session:
fresh_key = await new_session.get(key.__class__, key.hashed_key)
if not fresh_key:
logger.warning(
"Key not found when finalizing Responses API streaming payment",
extra={"key_hash": key.hashed_key[:8] + "..."},
)
usage_finalized = True
return None
return
try:
fallback: dict = {
"model": last_model_seen or "unknown",
"usage": None,
}
cost_data = await adjust_payment_for_tokens(
fresh_key, fallback, new_session, max_cost_for_model
await adjust_payment_for_tokens(
fresh_key,
{"model": last_model_seen or "unknown", "usage": None},
new_session,
max_cost_for_model,
)
usage_finalized = True
logger.info(
"Finalized Responses API streaming payment without explicit usage",
extra={
"key_hash": key.hashed_key[:8] + "...",
"cost_data": cost_data,
"balance_after_adjustment": fresh_key.balance,
},
)
return f"data: {json.dumps({'cost': cost_data})}\\n\\n".encode()
except Exception as cost_error:
logger.error(
"Error finalizing Responses API payment without usage",
extra={
"error": str(cost_error),
"error_type": type(cost_error).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
usage_finalized = True
return None
try:
async for chunk in response.aiter_bytes():
stored_chunks.append(chunk)
try:
for part in re.split(b"data: ", chunk):
if not part or part.strip() in (b"[DONE]", b""):
continue
try:
obj = json.loads(part)
if isinstance(obj, dict):
if obj.get("model"):
last_model_seen = str(obj.get("model"))
# Track reasoning tokens for Responses API
if usage := obj.get("usage", {}):
if (
isinstance(usage, dict)
and "reasoning_tokens" in usage
):
reasoning_tokens += usage.get(
"reasoning_tokens", 0
)
except json.JSONDecodeError:
pass
except Exception:
pass
yield chunk
try:
async for chunk in response.aiter_bytes():
# Split chunk into SSE events
parts = re.split(b"data: ", chunk)
for i, part in enumerate(parts):
if not part:
continue
logger.debug(
"Responses API streaming completed, analyzing usage data",
extra={
"key_hash": key.hashed_key[:8] + "...",
"chunks_count": len(stored_chunks),
"reasoning_tokens": reasoning_tokens,
},
)
stripped_part = part.strip()
if not stripped_part:
continue
# Process final usage data
for i in range(len(stored_chunks) - 1, -1, -1):
chunk = stored_chunks[i]
if not chunk:
continue
try:
events = re.split(b"data: ", chunk)
for event_data in events:
if not event_data or event_data.strip() in (b"[DONE]", b""):
continue
try:
data = json.loads(event_data)
if isinstance(data, dict) and data.get("model"):
last_model_seen = str(data.get("model"))
if isinstance(data, dict) and isinstance(
data.get("usage"), dict
):
# Include reasoning tokens in usage calculation
async with create_session() as new_session:
fresh_key = await new_session.get(
key.__class__, key.hashed_key
if stripped_part == b"[DONE]":
done_seen = True
continue
try:
obj = json.loads(part)
if isinstance(obj, dict):
if obj.get("model"):
last_model_seen = str(obj.get("model"))
# Track reasoning tokens for Responses API
if usage := obj.get("usage", {}):
if (
isinstance(usage, dict)
and "reasoning_tokens" in usage
):
reasoning_tokens += usage.get(
"reasoning_tokens", 0
)
if fresh_key:
try:
cost_data = (
await adjust_payment_for_tokens(
fresh_key,
data,
new_session,
max_cost_for_model,
)
)
usage_finalized = True
logger.info(
"Payment adjustment completed for Responses API streaming",
extra={
"key_hash": key.hashed_key[:8]
+ "...",
"cost_data": cost_data,
"model": last_model_seen,
"reasoning_tokens": reasoning_tokens,
"balance_after_adjustment": fresh_key.balance,
},
)
yield f"data: {json.dumps({'cost': cost_data})}\\n\\n".encode()
except Exception as cost_error:
logger.error(
"Error adjusting payment for Responses API 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 Responses API streaming response chunk",
extra={
"error": str(e),
"error_type": type(e).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
# Responses API usage is in response.completed/incomplete events
chunk_type = obj.get("type", "")
if chunk_type in (
"response.completed",
"response.incomplete",
):
usage_chunk_data = obj
continue
except json.JSONDecodeError:
pass
prefix = (
b"data: " if (i > 0 or chunk.startswith(b"data: ")) else b""
)
yield prefix + part
# Stream finished, process usage if found
if usage_chunk_data:
async with create_session() as session:
fresh_key = await session.get(key.__class__, key.hashed_key)
if fresh_key:
try:
cost_data = await adjust_payment_for_tokens(
fresh_key,
usage_chunk_data,
session,
max_cost_for_model,
)
# Merge cost into usage chunk
if (
"response" in usage_chunk_data
and "usage" in usage_chunk_data["response"]
):
usage_chunk_data["response"]["usage"]["cost"] = (
cost_data.get("total_usd", 0.0)
)
elif "usage" in usage_chunk_data:
usage_chunk_data["usage"]["cost"] = cost_data.get(
"total_usd", 0.0
)
# Keep detailed cost in metadata
usage_chunk_data["metadata"] = usage_chunk_data.get(
"metadata", {}
)
usage_chunk_data["metadata"]["routstr"] = {
"cost": cost_data
}
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
usage_finalized = True
except Exception:
# Fallback: yield original usage chunk if adjustment fails
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
if not usage_finalized:
maybe_cost_event = await finalize_without_usage()
if maybe_cost_event is not None:
yield maybe_cost_event
await finalize_db_only()
if done_seen:
yield b"data: [DONE]\n\n"
except Exception as stream_error:
logger.warning(
"Responses API streaming interrupted; finalizing without usage",
"Responses API streaming interrupted; finalizing in background",
extra={
"error": str(stream_error),
"error_type": type(stream_error).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
finally:
if not usage_finalized:
await finalize_without_usage()
await finalize_db_only()
# Remove inaccurate encoding headers from upstream response
response_headers = dict(response.headers)
@@ -962,6 +873,7 @@ class BaseUpstreamProvider:
},
)
content: bytes | None = None
try:
content = await response.aread()
response_json = json.loads(content)
@@ -981,6 +893,14 @@ class BaseUpstreamProvider:
cost_data = await adjust_payment_for_tokens(
key, response_json, session, deducted_max_cost
)
# Merge cost into usage for OpenCode
if "usage" in response_json:
response_json["usage"]["cost"] = cost_data.get("total_usd", 0.0)
# Keep detailed cost
response_json["metadata"] = response_json.get("metadata", {})
response_json["metadata"]["routstr"] = {"cost": cost_data}
response_json["cost"] = cost_data
logger.info(
+10 -6
View File
@@ -6,7 +6,7 @@ from fastapi import HTTPException
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.auth import adjust_payment_for_tokens, pay_for_request
from routstr.balance import create_child_key
from routstr.balance import ChildKeyRequest, create_child_key
from routstr.core.db import ApiKey
from routstr.core.settings import settings
@@ -27,13 +27,15 @@ async def test_child_key_flow(integration_session: AsyncSession) -> None:
settings.child_key_cost = 1000 # 1 sat
# 2. Call create_child_key
result = await create_child_key(parent_key, integration_session)
result = await create_child_key(
ChildKeyRequest(count=1), parent_key, integration_session
)
assert "api_key" in result
assert "api_keys" in result
assert result["cost_msats"] == 1000
assert result["parent_balance"] == 9000
child_key_raw = result["api_key"][3:] # remove sk-
child_key_raw = result["api_keys"][0][3:] # remove sk-
# 3. Verify child key exists in DB
child_key_db = await integration_session.get(ApiKey, child_key_raw)
@@ -111,7 +113,9 @@ async def test_child_key_insufficient_balance(
settings.child_key_cost = 1000
with pytest.raises(HTTPException) as exc:
await create_child_key(parent_key, integration_session)
await create_child_key(
ChildKeyRequest(count=1), parent_key, integration_session
)
assert exc.value.status_code == 402
@@ -132,6 +136,6 @@ async def test_child_key_cannot_create_child(integration_session: AsyncSession)
await integration_session.refresh(child_key)
with pytest.raises(HTTPException) as exc:
await create_child_key(child_key, integration_session)
await create_child_key(ChildKeyRequest(count=1), child_key, integration_session)
assert exc.value.status_code == 400
assert "Cannot create a child key for another child key" in str(exc.value.detail)
+286
View File
@@ -0,0 +1,286 @@
'use client';
import { useState } from 'react';
import { WalletService } from '@/lib/api/services/wallet';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Input } from '@/components/ui/input';
import { Key, Copy, Check, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
interface ChildKeyCreatorProps {
baseUrl?: string;
apiKey?: string;
onApiKeyChange?: (apiKey: string) => void;
costPerKeyMsats?: number;
}
export function ChildKeyCreator({
baseUrl,
apiKey: propApiKey,
onApiKeyChange,
costPerKeyMsats,
}: ChildKeyCreatorProps) {
const [internalApiKey, setInternalApiKey] = useState('');
const [loading, setLoading] = useState(false);
const [count, setCount] = useState(1);
const [newKeys, setNewKeys] = useState<string[]>([]);
const [resultInfo, setResultInfo] = useState<{
cost_msats: number;
parent_balance: number;
} | null>(null);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const activeApiKey = propApiKey ?? internalApiKey;
const handleApiKeyChange = (val: string) => {
setInternalApiKey(val);
onApiKeyChange?.(val);
};
const handleCreateKey = async () => {
if (!activeApiKey && baseUrl) {
toast.error('Please provide a Parent API key first');
return;
}
const requestedCount = Math.max(1, Math.min(50, Number(count)));
setLoading(true);
try {
const result = await WalletService.createChildKey(
baseUrl,
activeApiKey,
requestedCount
);
console.log('Created child keys:', result);
if (result.api_keys && result.api_keys.length > 0) {
setNewKeys(result.api_keys);
} else {
throw new Error('No API keys returned from server');
}
setResultInfo({
cost_msats: result.cost_msats,
parent_balance: result.parent_balance,
});
toast.success(
`${requestedCount} child API key${
requestedCount > 1 ? 's' : ''
} created successfully`
);
} catch (error) {
console.error('Failed to create child key:', error);
toast.error(
error instanceof Error ? error.message : 'Failed to create child key'
);
} finally {
setLoading(false);
}
};
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key);
setCopiedKey(key);
toast.success('API key copied to clipboard');
setTimeout(() => setCopiedKey(null), 2000);
};
const copyAllToClipboard = () => {
navigator.clipboard.writeText(newKeys.join('\n'));
toast.success('All API keys copied to clipboard');
};
return (
<div className='space-y-6'>
<Card>
<CardHeader>
<div className='flex items-center justify-between'>
<div className='space-y-1'>
<CardTitle>Create Child API Key</CardTitle>
<CardDescription>
Generate secondary API keys that share your account balance.
</CardDescription>
</div>
{costPerKeyMsats !== undefined && (
<div className='text-right'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
Unit Cost
</p>
<p className='text-primary text-sm font-bold'>
{costPerKeyMsats / 1000} sats
</p>
</div>
)}
</div>
</CardHeader>
<CardContent>
<div className='space-y-4'>
{baseUrl && (
<div className='space-y-2'>
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
Parent API Key
</label>
<Input
value={activeApiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
/>
</div>
)}
<div className='flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between'>
<div className='flex-1 space-y-2'>
<div className='flex items-center justify-between'>
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
Number of keys
</label>
{costPerKeyMsats && (
<span className='text-muted-foreground text-[10px]'>
Cost: {costPerKeyMsats * count} mSats
</span>
)}
</div>
<Input
type='number'
min={1}
max={50}
value={count}
onChange={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val)) {
setCount(Math.max(1, Math.min(50, val)));
} else {
setCount(1);
}
}}
className='w-full sm:w-24'
/>
</div>
<Button
onClick={handleCreateKey}
disabled={loading || (!!baseUrl && !activeApiKey)}
className='w-full sm:w-auto'
>
{loading ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Creating...
</>
) : (
<>
<Key className='mr-2 h-4 w-4' />
Generate {count > 1 ? `${count} Keys` : 'Key'}
</>
)}
</Button>
</div>
<p className='text-muted-foreground text-xs'>
Each key creation has a small one-time fee.
</p>
{newKeys.length > 0 && (
<div className='mt-6 space-y-4'>
<Alert className='border-green-200 bg-green-50 dark:border-green-900/20 dark:bg-green-900/10'>
<AlertTitle className='text-green-800 dark:text-green-400'>
{newKeys.length} New API Key{newKeys.length > 1 ? 's' : ''}{' '}
Generated
</AlertTitle>
<AlertDescription className='text-green-700 dark:text-green-500'>
Copy {newKeys.length > 1 ? 'these keys' : 'this key'} now.
You won&apos;t be able to see them again.
{resultInfo && (
<div className='mt-2 font-medium opacity-80'>
Total Cost: {resultInfo.cost_msats / 1000} sats | New
Balance: {resultInfo.parent_balance / 1000} sats
</div>
)}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-xs font-medium uppercase'>
Generated Keys ({newKeys.length})
</span>
{newKeys.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-7 text-[10px] uppercase'
onClick={copyAllToClipboard}
>
<Copy className='mr-1 h-3 w-3' />
Copy All
</Button>
)}
</div>
<div className='grid gap-2'>
{newKeys.map((key, index) => (
<div
key={index}
className='group relative flex items-center gap-2'
>
<code className='bg-muted/50 flex-1 rounded border p-2.5 font-mono text-[10px] break-all sm:text-xs'>
{key}
</code>
<Button
size='icon'
variant='ghost'
className='h-8 w-8 shrink-0'
onClick={() => copyToClipboard(key)}
>
{copiedKey === key ? (
<Check className='h-3.5 w-3.5 text-green-500' />
) : (
<Copy className='h-3.5 w-3.5 opacity-50 group-hover:opacity-100' />
)}
</Button>
</div>
))}
</div>
</div>
{newKeys.length > 3 && (
<div className='space-y-2'>
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
Bulk Export (All Keys)
</label>
<div className='relative'>
<textarea
readOnly
value={newKeys.join('\n')}
rows={Math.min(newKeys.length, 6)}
className='bg-muted/30 w-full rounded-md border p-3 font-mono text-[10px] focus:outline-none'
/>
<Button
size='sm'
variant='secondary'
className='absolute right-2 bottom-2 h-7 text-[10px]'
onClick={copyAllToClipboard}
>
Copy Bulk
</Button>
</div>
</div>
)}
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}
+13 -1
View File
@@ -14,6 +14,7 @@ import { ConfigurationService } from '@/lib/api/services/configuration';
import { CashuPaymentWorkflow } from './cashu-payment-workflow';
import { LightningPaymentWorkflow } from './lightning-payment-workflow';
import { ApiKeyManager } from './api-key-manager';
import { ChildKeyCreator } from '@/components/child-key-creator';
type NodeInfo = {
name: string;
@@ -23,6 +24,7 @@ type NodeInfo = {
mints: string[];
http_url?: string | null;
onion_url?: string | null;
child_key_cost_msats?: number;
};
type WalletSnapshot = {
@@ -362,10 +364,11 @@ export function CheatSheet(): JSX.Element {
</section>
<Tabs defaultValue='cashu' className='w-full'>
<TabsList className='grid w-full grid-cols-3'>
<TabsList className='grid w-full grid-cols-4'>
<TabsTrigger value='cashu'>Cashu Payments</TabsTrigger>
<TabsTrigger value='lightning'>Lightning Payments</TabsTrigger>
<TabsTrigger value='manage'>Manage Keys</TabsTrigger>
<TabsTrigger value='child-keys'>Child Keys</TabsTrigger>
</TabsList>
<TabsContent value='cashu' className='space-y-4'>
@@ -422,6 +425,15 @@ export function CheatSheet(): JSX.Element {
</Card>
)}
</TabsContent>
<TabsContent value='child-keys' className='space-y-4'>
<ChildKeyCreator
baseUrl={normalizedBaseUrl}
apiKey={apiKeyInput}
onApiKeyChange={handleApiKeyChanged}
costPerKeyMsats={nodeInfo?.child_key_cost_msats}
/>
</TabsContent>
</Tabs>
</main>
</div>
+54 -11
View File
@@ -29,6 +29,28 @@ export type RedeemTokenResponse = z.infer<typeof RedeemTokenResponseSchema>;
export type SendTokenRequest = z.infer<typeof SendTokenRequestSchema>;
export type SendTokenResponse = z.infer<typeof SendTokenResponseSchema>;
export interface BalanceDetail {
mint_url: string;
unit: string;
wallet_balance: number;
user_balance: number;
owner_balance: number;
error?: string;
}
export interface WithdrawResponse {
token: string;
}
export interface CreateChildKeyResponse {
api_keys: string[];
count: number;
cost_msats: number;
cost_sats: number;
parent_balance: number;
parent_balance_sats: number;
}
export class WalletService {
static async redeemToken(token: string): Promise<RedeemTokenResponse> {
try {
@@ -108,17 +130,38 @@ export class WalletService {
throw error;
}
}
}
export interface BalanceDetail {
mint_url: string;
unit: string;
wallet_balance: number;
user_balance: number;
owner_balance: number;
error?: string;
}
static async createChildKey(
baseUrl?: string,
apiKey?: string,
count: number = 1
): Promise<CreateChildKeyResponse> {
try {
if (baseUrl && apiKey) {
const response = await fetch(`${baseUrl}/v1/balance/child-key`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ count }),
});
export interface WithdrawResponse {
token: string;
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to create child key');
}
return (await response.json()) as CreateChildKeyResponse;
}
return await apiClient.post<CreateChildKeyResponse>(
'/v1/balance/child-key',
{ count }
);
} catch (error) {
console.error('Error creating child key:', error);
throw error;
}
}
}
Generated
+1 -1
View File
@@ -1878,7 +1878,7 @@ wheels = [
[[package]]
name = "routstr"
version = "0.2.2"
version = "0.3.0"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },