Compare commits

..
Author SHA1 Message Date
Shroominic 31898192b2 added missing delete button for custom models 2026-01-29 13:05:56 +08:00
shroominicandGitHub 855d60b4a5 Merge pull request #330 from Routstr/fix-provider-model-pricing
fix provider model pricing
2026-01-29 10:58:27 +08:00
shroominicandGitHub 27ace348b5 Merge pull request #329 from Routstr/fix-multiple-custom-models
fix: unable to add multiple custom models
2026-01-29 10:58:14 +08:00
Shroominic 73e3d34623 fix provider model pricing 2026-01-29 08:50:17 +08:00
Shroominic c8f8857f03 fckng prettier again 2026-01-28 18:12:54 +08:00
Shroominic c9650441bb fix build error 2026-01-28 18:11:07 +08:00
Shroominic 5ce9c2217f fix fmt 2026-01-28 18:04:56 +08:00
Shroominic 89b8488ab8 prettier 2026-01-28 17:58:24 +08:00
Shroominic 2ee917fa31 fix not being able to add multiple custom models when provider does not have Provided Models 2026-01-28 17:55:06 +08:00
9qeklajcandGitHub 5e12a7e92d Merge pull request #327 from Routstr/fix/multi-provider-base-url
Allow multiple providers per base URL
2026-01-27 09:24:20 +01:00
9qeklajcandGitHub 39e0959fcd Merge pull request #326 from Routstr/feat/preset-selector-override-modal
Add preset selector to model override modal
2026-01-27 09:19:56 +01:00
9qeklajcandGitHub 29be9d5b9c Merge pull request #323 from Routstr/create-child-key-ui
create child keys within the dashboard
2026-01-27 09:07:05 +01:00
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
Shroominic 6d5b811c20 feat(ui): add preset selector to model override modal
Add the same preset selector that exists in the custom model creation
modal to the model override modal. This allows users to apply pricing
and settings from OpenRouter presets when creating overrides.

- Show preset selector for override mode (not just create mode)
- Preserve original model ID when applying preset in override mode
- Add contextual help text for override vs create mode

Closes #324
2026-01-25 22:04:51 +08: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
12 changed files with 641 additions and 276 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
+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(
+6 -6
View File
@@ -2516,7 +2516,7 @@ async def get_provider_model(provider_id: int, model_id: str) -> dict[str, objec
status_code=404, detail="Model not found for this provider"
)
return _row_to_model(
row, apply_provider_fee=True, provider_fee=provider.provider_fee
row, apply_provider_fee=False, provider_fee=provider.provider_fee
).dict() # type: ignore
@@ -2729,7 +2729,10 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
raise HTTPException(status_code=404, detail="Provider not found")
db_models = await list_models(
session=session, upstream_id=provider_id, include_disabled=True
session=session,
upstream_id=provider_id,
include_disabled=True,
apply_fees=False,
)
upstream_models = []
@@ -2737,10 +2740,7 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
if upstream_instance:
try:
raw_models = await upstream_instance.fetch_models()
upstream_models = [
upstream_instance._apply_provider_fee_to_model(m)
for m in raw_models
]
upstream_models = raw_models
except Exception as e:
logger.error(
f"Failed to fetch models from {provider.provider_type}: {e}"
+1
View File
@@ -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,
}
+2 -1
View File
@@ -230,6 +230,7 @@ async def list_models(
session: AsyncSession,
upstream_id: int,
include_disabled: bool = False,
apply_fees: bool = True,
) -> list[Model]:
from sqlmodel import select
@@ -247,7 +248,7 @@ async def list_models(
return [
_row_to_model(
r,
apply_provider_fee=True,
apply_provider_fee=apply_fees,
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
if r.upstream_provider_id in providers_by_id
else 1.01,
+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)
+132 -161
View File
@@ -286,7 +286,9 @@ function ProviderBalance({
<div className='rounded-lg border-2 border-gray-200 p-2 dark:border-gray-800'>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodeURIComponent(invoiceData.payment_request)}`}
src={`https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodeURIComponent(
invoiceData.payment_request
)}`}
alt='Lightning Invoice QR Code'
className='h-64 w-64'
/>
@@ -482,6 +484,25 @@ export default function ProvidersPage() {
},
});
const deleteModelMutation = useMutation({
mutationFn: ({
providerId,
modelId,
}: {
providerId: number;
modelId: string;
}) => AdminService.deleteProviderModel(providerId, modelId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ['provider-models', variables.providerId],
});
toast.success('Model deleted successfully');
},
onError: (error: Error) => {
toast.error(`Failed to delete model: ${error.message}`);
},
});
const handleCreateAccount = async () => {
setIsCreatingAccount(true);
try {
@@ -560,6 +581,12 @@ export default function ProvidersPage() {
}
};
const handleDeleteModel = (providerId: number, modelId: string) => {
if (confirm('Are you sure you want to delete this model?')) {
deleteModelMutation.mutate({ providerId, modelId });
}
};
const getDefaultBaseUrl = (type: string) => {
const providerType = providerTypes.find((pt) => pt.id === type);
return providerType?.default_base_url || '';
@@ -933,25 +960,71 @@ export default function ProvidersPage() {
</div>
) : providerModels &&
viewingModels === provider.id ? (
providerModels.remote_models.length === 0 ? (
// No provided models - show custom models directly without tabs
<div className='space-y-2'>
{providerModels.db_models.length === 0 ? (
<div className='flex flex-col items-center justify-center gap-2 py-4'>
<Tabs
defaultValue={
providerModels.remote_models.length > 0
? 'provided'
: 'custom'
}
className='w-full'
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger
value='provided'
className='text-xs sm:text-sm'
>
<span className='hidden sm:inline'>
Provided Models
</span>
<span className='sm:hidden'>Provided</span>
<Badge
variant='secondary'
className='ml-1 text-xs sm:ml-2'
>
{providerModels.remote_models.length}
</Badge>
</TabsTrigger>
<TabsTrigger
value='custom'
className='text-xs sm:text-sm'
>
<span className='hidden sm:inline'>
Custom Models
</span>
<span className='sm:hidden'>Custom</span>
<Badge
variant='secondary'
className='ml-1 text-xs sm:ml-2'
>
{providerModels.db_models.length}
</Badge>
</TabsTrigger>
</TabsList>
<TabsContent
value='custom'
className='mt-4 space-y-2'
>
<div className='flex items-center justify-between'>
{providerModels.db_models.length > 0 && (
<div className='text-muted-foreground text-sm'>
No models configured. Add custom models
to use this provider.
Custom models override or extend the
provider&apos;s catalog.
</div>
<Button
variant='outline'
size='sm'
onClick={() =>
handleAddModel(provider.id)
}
>
<Plus className='mr-2 h-4 w-4' />
Add Custom Model
</Button>
)}
<Button
variant='outline'
size='sm'
onClick={() =>
handleAddModel(provider.id)
}
>
<Plus className='mr-2 h-4 w-4' />
Add
</Button>
</div>
{providerModels.db_models.length === 0 ? (
<div className='text-muted-foreground py-4 text-center text-sm'>
No custom models configured
</div>
) : (
<div className='space-y-2'>
@@ -1000,103 +1073,48 @@ export default function ProvidersPage() {
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='icon'
className='text-destructive hover:text-destructive h-8 w-8'
onClick={() =>
handleDeleteModel(
provider.id,
model.id
)
}
disabled={
deleteModelMutation.isPending
}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</div>
))}
</div>
)}
</div>
) : (
// Has provided models - show tabs
<Tabs
defaultValue='provided'
className='w-full'
</TabsContent>
<TabsContent
value='provided'
className='mt-4 space-y-2'
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger
value='provided'
className='text-xs sm:text-sm'
>
<span className='hidden sm:inline'>
Provided Models
</span>
<span className='sm:hidden'>
Provided
</span>
<Badge
variant='secondary'
className='ml-1 text-xs sm:ml-2'
>
{providerModels.remote_models.length}
</Badge>
</TabsTrigger>
<TabsTrigger
value='custom'
className='text-xs sm:text-sm'
>
<span className='hidden sm:inline'>
Custom Models
</span>
<span className='sm:hidden'>Custom</span>
<Badge
variant='secondary'
className='ml-1 text-xs sm:ml-2'
>
{providerModels.db_models.length}
</Badge>
</TabsTrigger>
</TabsList>
<TabsContent
value='custom'
className='mt-4 space-y-2'
>
<div className='flex items-center justify-between'>
{providerModels.db_models.length > 0 && (
<div className='text-muted-foreground text-sm'>
Custom models override or extend the
provider&apos;s catalog.
</div>
)}
<Button
variant='outline'
size='sm'
onClick={() =>
handleAddModel(provider.id)
}
>
<Plus className='mr-2 h-4 w-4' />
Add
</Button>
</div>
{providerModels.db_models.length === 0 ? (
<div className='text-muted-foreground py-4 text-center text-sm'>
No custom models configured
{providerModels.remote_models.length > 0 ? (
<>
<div className='text-muted-foreground mb-3 text-sm'>
Models automatically discovered from the
provider&apos;s catalog.
</div>
) : (
<div className='space-y-2'>
{providerModels.db_models.map(
{providerModels.remote_models.map(
(model) => (
<div
key={model.id}
className='hover:bg-accent flex flex-col gap-2 rounded-lg border p-3 transition-colors sm:flex-row sm:items-center sm:justify-between'
>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2'>
<span className='truncate font-mono text-sm font-medium'>
{model.id}
</span>
<Badge
variant={
model.enabled
? 'default'
: 'secondary'
}
className='w-fit text-xs'
>
{model.enabled
? 'Enabled'
: 'Disabled'}
</Badge>
<div className='truncate font-mono text-sm font-medium'>
{model.id}
</div>
<div className='text-muted-foreground mt-1 text-xs break-words'>
{model.description ||
@@ -1109,79 +1127,32 @@ export default function ProvidersPage() {
tokens
</div>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() =>
handleEditModel(
handleOverrideModel(
provider.id,
model
)
}
>
<Pencil className='h-4 w-4' />
<Plus className='mr-1 h-3 w-3' />
Override
</Button>
</div>
</div>
)
)}
</div>
)}
</TabsContent>
<TabsContent
value='provided'
className='mt-4 space-y-2'
>
{providerModels.remote_models.length >
0 && (
<div className='text-muted-foreground mb-3 text-sm'>
Models automatically discovered from the
provider&apos;s catalog.
</div>
)}
<div className='space-y-2'>
{providerModels.remote_models.map(
(model) => (
<div
key={model.id}
className='hover:bg-accent flex flex-col gap-2 rounded-lg border p-3 transition-colors sm:flex-row sm:items-center sm:justify-between'
>
<div className='min-w-0 flex-1'>
<div className='truncate font-mono text-sm font-medium'>
{model.id}
</div>
<div className='text-muted-foreground mt-1 text-xs break-words'>
{model.description ||
model.name}
</div>
</div>
<div className='flex items-center gap-2'>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
<Button
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() =>
handleOverrideModel(
provider.id,
model
)
}
>
<Plus className='mr-1 h-3 w-3' />
Override
</Button>
</div>
</div>
)
)}
</>
) : (
<div className='text-muted-foreground py-4 text-center text-sm'>
No provided models available
</div>
</TabsContent>
</Tabs>
)
)}
</TabsContent>
</Tabs>
) : null}
</div>
)}
+7 -43
View File
@@ -248,7 +248,9 @@ export function AddProviderModelDialog({
const pricing = model.pricing as Record<string, number>;
const topProvider = model.top_provider as Record<string, unknown> | null;
form.setValue('id', model.id);
if (!isOverride) {
form.setValue('id', model.id);
}
form.setValue('name', model.name);
form.setValue('description', model.description || '');
form.setValue('context_length', model.context_length);
@@ -425,7 +427,7 @@ export function AddProviderModelDialog({
</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{!isEdit && !isOverride && (
{!isEdit && (
<div className='bg-muted/30 rounded-md border p-3'>
<div className='mb-2 text-sm font-medium'>Presets</div>
<div className='grid gap-2 sm:grid-cols-3 sm:items-start'>
@@ -488,8 +490,9 @@ export function AddProviderModelDialog({
</Popover>
</div>
<div className='text-muted-foreground mt-1 text-xs'>
Prefill fields from a preset model definition, then adjust as
needed.
{isOverride
? 'Apply pricing and settings from a preset model (keeping the model ID unchanged).'
: 'Prefill fields from a preset model definition, then adjust as needed.'}
</div>
</div>
)}
@@ -805,45 +808,6 @@ export function AddProviderModelDialog({
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_prompt_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Max Prompt Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_completion_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Max Completion Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Max Total Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
+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 -29
View File
@@ -139,18 +139,26 @@ export class AdminService {
): Record<string, unknown> {
if (!pricing) return pricing;
const result = { ...pricing };
if (typeof result.prompt === 'number') {
result.prompt = result.prompt * 1000000;
}
if (typeof result.completion === 'number') {
result.completion = result.completion * 1000000;
}
if (typeof result.request === 'number') {
result.request = result.request * 1000000;
}
if (typeof result.image === 'number') {
result.image = result.image * 1000000;
}
// Only prompt and completion are per-token and need scaling to per-1M
const convertField = (field: string) => {
const val = result[field];
if (val !== undefined && val !== null) {
const num = typeof val === 'string' ? parseFloat(val) : (val as number);
if (!isNaN(num)) {
// Multiply by 1M and round to avoid floating point artifacts (e.g. 0.40399999999999997)
// 9 decimals is plenty for USD/1M tokens (0.000000001)
result[field] = parseFloat((num * 1000000).toFixed(9));
}
}
};
convertField('prompt');
convertField('completion');
// Other fields (request, image, etc.) are already flat fees (per item)
// so we do NOT scale them.
return result;
}
@@ -159,18 +167,23 @@ export class AdminService {
): Record<string, unknown> {
if (!pricing) return pricing;
const result = { ...pricing };
if (typeof result.prompt === 'number') {
result.prompt = result.prompt / 1000000;
}
if (typeof result.completion === 'number') {
result.completion = result.completion / 1000000;
}
if (typeof result.request === 'number') {
result.request = result.request / 1000000;
}
if (typeof result.image === 'number') {
result.image = result.image / 1000000;
}
// Only prompt and completion are per-1M in UI and need scaling down to per-token
const convertField = (field: string) => {
const val = result[field];
if (val !== undefined && val !== null) {
const num = typeof val === 'string' ? parseFloat(val) : (val as number);
if (!isNaN(num)) {
result[field] = num / 1000000;
}
}
};
convertField('prompt');
convertField('completion');
// Other fields stay as flat fees
return result;
}
@@ -291,7 +304,19 @@ export class AdminService {
const data = await apiClient.get<ProviderModels>(
`/admin/api/upstream-providers/${providerId}/models`
);
return data;
// Convert pricing for all models in the list so the UI receives "per 1M tokens" values
return {
...data,
db_models: data.db_models.map((m) => ({
...m,
pricing: this.convertPricingToPerMillionTokens(m.pricing),
})),
remote_models: data.remote_models.map((m) => ({
...m,
pricing: this.convertPricingToPerMillionTokens(m.pricing),
})),
};
}
static async createProviderModel(
@@ -498,8 +523,8 @@ export class AdminService {
: null;
const pricing = {
prompt: (data.input_cost as number) / 1000000,
completion: (data.output_cost as number) / 1000000,
prompt: data.input_cost as number,
completion: data.output_cost as number,
request: (data.min_cost_per_request as number) || 0,
image: 0,
web_search: 0,
@@ -553,8 +578,8 @@ export class AdminService {
const existingModel = await this.getModel(modelId, providerId);
const pricing = {
prompt: (data.input_cost as number) / 1000000,
completion: (data.output_cost as number) / 1000000,
prompt: data.input_cost as number,
completion: data.output_cost as number,
request: (data.min_cost_per_request as number) || 0,
image: 0,
web_search: 0,
+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;
}
}
}