diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 952c9373..f6b0c0f0 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -360,6 +360,42 @@ POST /v1/wallet/create } ``` +### Get Key Information + +Get current balance, consumption data, and child keys for an API key. + +```http +GET /v1/balance/info +Authorization: Bearer sk-... +``` + +**Response:** + +```json +{ + "api_key": "sk-abc...", + "balance": 8500000, + "reserved": 0, + "is_child": false, + "parent_key": null, + "total_requests": 42, + "total_spent": 1500000, + "balance_limit": null, + "balance_limit_reset": null, + "validity_date": null, + "child_keys": [ + { + "api_key": "sk-child1...", + "total_requests": 10, + "total_spent": 500000, + "balance_limit": 1000000, + "balance_limit_reset": "daily", + "validity_date": 1738000000 + } + ] +} +``` + ### Check Balance Get current wallet balance. diff --git a/routstr/balance.py b/routstr/balance.py index 537a5324..84ea01d9 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -6,8 +6,9 @@ from typing import Annotated, NoReturn from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel +from sqlmodel import select -from .auth import validate_bearer_key +from .auth import get_billing_key, validate_bearer_key from .core.db import ApiKey, AsyncSession, get_session from .core.logging import get_logger from .core.settings import settings @@ -34,10 +35,8 @@ async def get_key_from_header( async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict: - from .auth import get_billing_key - billing_key = await get_billing_key(key, session) - return { + info = { "api_key": "sk-" + key.hashed_key, "balance": billing_key.balance, "reserved": billing_key.reserved_balance, @@ -50,6 +49,26 @@ async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict: "validity_date": key.validity_date, } + if not key.parent_key_hash: + # Fetch child keys if this is a parent key + statement = select(ApiKey).where(ApiKey.parent_key_hash == key.hashed_key) + results = await session.exec(statement) + child_keys = results.all() + if child_keys: + info["child_keys"] = [ + { + "api_key": "sk-" + ck.hashed_key, + "total_requests": ck.total_requests, + "total_spent": ck.total_spent, + "balance_limit": ck.balance_limit, + "balance_limit_reset": ck.balance_limit_reset, + "validity_date": ck.validity_date, + } + for ck in child_keys + ] + + return info + # TODO: remove this endpoint when frontend is updated @router.get("/", include_in_schema=False) @@ -117,8 +136,6 @@ async def topup_wallet_endpoint( key: ApiKey = Depends(get_key_from_header), session: AsyncSession = Depends(get_session), ) -> dict[str, int]: - from .auth import get_billing_key - billing_key = await get_billing_key(key, session) if topup_request is not None: diff --git a/routstr/core/admin.py b/routstr/core/admin.py index c0ccbe93..3d123718 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -456,18 +456,18 @@ async def batch_override_provider_models( logger.info( f"BATCH_OVERRIDE called: provider_id={provider_id}, count={len(payload.models)}" ) - + async with create_session() as session: provider = await session.get(UpstreamProviderRow, provider_id) if not provider: raise HTTPException(status_code=404, detail="Provider not found") - + overridden_count = 0 - + for model_data in payload.models: # Try to get existing model regardless of whether it's enabled or not existing_row = await session.get(ModelRow, (model_data.id, provider_id)) - + if existing_row: # Update existing existing_row.name = model_data.name @@ -483,7 +483,9 @@ async def batch_override_provider_models( else None ) existing_row.top_provider = ( - json.dumps(model_data.top_provider) if model_data.top_provider else None + json.dumps(model_data.top_provider) + if model_data.top_provider + else None ) existing_row.canonical_slug = model_data.canonical_slug existing_row.alias_ids = ( @@ -508,23 +510,32 @@ async def batch_override_provider_models( else None ), top_provider=( - json.dumps(model_data.top_provider) if model_data.top_provider else None + json.dumps(model_data.top_provider) + if model_data.top_provider + else None ), canonical_slug=model_data.canonical_slug, alias_ids=( - json.dumps(model_data.alias_ids) if model_data.alias_ids else None + json.dumps(model_data.alias_ids) + if model_data.alias_ids + else None ), upstream_provider_id=provider_id, enabled=model_data.enabled, ) session.add(row) - + overridden_count += 1 - + await session.commit() await refresh_model_maps() - return {"ok": True, "count": overridden_count, "message": f"Successfully batch overridden {overridden_count} models"} + return { + "ok": True, + "count": overridden_count, + "message": f"Successfully batch overridden {overridden_count} models", + } + class UpstreamProviderCreate(BaseModel): provider_type: str diff --git a/tests/integration/test_child_keys_api.py b/tests/integration/test_child_keys_api.py new file mode 100644 index 00000000..7bfaf2a8 --- /dev/null +++ b/tests/integration/test_child_keys_api.py @@ -0,0 +1,97 @@ +from typing import Any + +import pytest +from httpx import AsyncClient + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_wallet_info_returns_child_keys( + integration_client: AsyncClient, + authenticated_client: AsyncClient, + integration_session: Any, +) -> None: + """Test that GET /v1/wallet/info returns child keys for a parent key""" + + # 1. Get parent info to find its hashed_key + response = await authenticated_client.get("/v1/wallet/info") + assert response.status_code == 200 + parent_data = response.json() + parent_data["api_key"] + + # 2. Create child keys for this parent + # We need to use the parent's authentication for this + child_payload = {"count": 2, "balance_limit": 1000, "balance_limit_reset": "daily"} + create_response = await authenticated_client.post( + "/v1/wallet/child-key", json=child_payload + ) + assert create_response.status_code == 200 + create_data = create_response.json() + child_keys = create_data["api_keys"] + assert len(child_keys) == 2 + + # 3. Call /info again and check for child_keys + info_response = await authenticated_client.get("/v1/wallet/info") + assert info_response.status_code == 200 + info_data = info_response.json() + + assert "child_keys" in info_data + assert len(info_data["child_keys"]) == 2 + + # Verify child key details + for ck in info_data["child_keys"]: + assert ck["api_key"] in child_keys + assert ck["balance_limit"] == 1000 + assert ck["balance_limit_reset"] == "daily" + assert "total_spent" in ck + assert "total_requests" in ck + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_wallet_info_child_key_no_child_keys( + integration_client: AsyncClient, + authenticated_client: AsyncClient, + integration_session: Any, +) -> None: + """Test that GET /v1/wallet/info for a child key does NOT return child_keys""" + + # 1. Create a child key + child_payload = {"count": 1} + create_response = await authenticated_client.post( + "/v1/wallet/child-key", json=child_payload + ) + assert create_response.status_code == 200 + child_key = create_response.json()["api_keys"][0] + + # 2. Use the child key to get its info + integration_client.headers["Authorization"] = f"Bearer {child_key}" + info_response = await integration_client.get("/v1/wallet/info") + assert info_response.status_code == 200 + info_data = info_response.json() + + assert info_data["is_child"] is True + assert "child_keys" not in info_data + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_account_info_root_returns_child_keys( + authenticated_client: AsyncClient, +) -> None: + """Test that GET / returns child keys for a parent key (root endpoint)""" + + # 1. Create a child key + child_payload = {"count": 1} + await authenticated_client.post("/v1/wallet/child-key", json=child_payload) + + # 2. Call root endpoint /v1/balance/ + # Note: routstr/balance.py defines router = APIRouter() + # and it is included in balance_router with prefix /v1/balance + # The endpoint is @router.get("/") + response = await authenticated_client.get("/v1/balance/") + assert response.status_code == 200 + data = response.json() + + assert "child_keys" in data + assert len(data["child_keys"]) >= 1 diff --git a/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx index e37791e2..97ddae80 100644 --- a/ui/components/child-key-creator.tsx +++ b/ui/components/child-key-creator.tsx @@ -251,7 +251,7 @@ export function ChildKeyCreator({ )}
+ {walletInfo.parentKey}
+
+
+ + {formatSats(walletInfo.totalSpent)} sats +
++ {formatMsats(walletInfo.totalSpent)} msats +
+
+ {ck.api_key}
+
+ + Requests +
++ {ck.total_requests} +
++ Spent +
++ {formatSats(ck.total_spent)} sats +
++ Limit +
++ {ck.balance_limit + ? `${formatSats(ck.balance_limit)} sats` + : 'None'} +
++ Policy +
++ {ck.balance_limit_reset || 'None'} +
++ Expires +
++ {formatDate(ck.validity_date)} +
+