mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-01 08:16:13 +00:00
Compare commits
194
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3396e0cd47 | ||
|
|
855d60b4a5 | ||
|
|
27ace348b5 | ||
|
|
73e3d34623 | ||
|
|
c8f8857f03 | ||
|
|
c9650441bb | ||
|
|
5ce9c2217f | ||
|
|
89b8488ab8 | ||
|
|
2ee917fa31 | ||
|
|
5e12a7e92d | ||
|
|
1d043cd98d | ||
|
|
39e0959fcd | ||
|
|
29be9d5b9c | ||
|
|
1ebb7d71e1 | ||
|
|
bbf1e65a5d | ||
|
|
51c3e5dcd7 | ||
|
|
788075f656 | ||
|
|
0bbcacd186 | ||
|
|
6d5b811c20 | ||
|
|
42258ae39c | ||
|
|
04d6903369 | ||
|
|
b0b2ceb1a0 | ||
|
|
9dfa58d69f | ||
|
|
22ec9c3132 | ||
|
|
b72c578954 | ||
|
|
7e299180fe | ||
|
|
f290534df4 | ||
|
|
8d3c064b29 | ||
|
|
4ac96ade5f | ||
|
|
180a469399 | ||
|
|
87fbb48ca8 | ||
|
|
3465a44d0e | ||
|
|
a4259af38f | ||
|
|
0d07dd0cdb | ||
|
|
24015ebec1 | ||
|
|
db021866d8 | ||
|
|
d4339287be | ||
|
|
88bcc0edcb | ||
|
|
917a4d32b1 | ||
|
|
daf17f51ab | ||
|
|
fc042c768c | ||
|
|
3bc38937e8 | ||
|
|
9229b87b70 | ||
|
|
367265b9fe | ||
|
|
0b3ccb5fb0 | ||
|
|
dbd43f52fb | ||
|
|
39657ed64f | ||
|
|
493b4f0f1f | ||
|
|
cb36189db3 | ||
|
|
d2487f42b0 | ||
|
|
5255fce7b2 | ||
|
|
ca7e8bec71 | ||
|
|
c4cc09d61e | ||
|
|
ee668ee93b | ||
|
|
cf8b990fc7 | ||
|
|
78dd74845b | ||
|
|
e7f4c98475 | ||
|
|
fad792068e | ||
|
|
21d363f6aa | ||
|
|
5e21f6ccbc | ||
|
|
1e2d130022 | ||
|
|
1e21dce735 | ||
|
|
b7603dcf69 | ||
|
|
7dccfa745f | ||
|
|
54d5118980 | ||
|
|
7723ab4a95 | ||
|
|
86c022d8db | ||
|
|
21ae22abec | ||
|
|
3a939d0dd1 | ||
|
|
50eabafa57 | ||
|
|
d192a6a6b4 | ||
|
|
7d829af681 | ||
|
|
57bf1b68d9 | ||
|
|
00d0415518 | ||
|
|
e8585b276f | ||
|
|
4b5e911435 | ||
|
|
761aabfec3 | ||
|
|
f0c45a7ce4 | ||
|
|
fc8ccf63ba | ||
|
|
eeb70e4ee5 | ||
|
|
a3b410b467 | ||
|
|
9e9bc5bff8 | ||
|
|
bdf0e2c192 | ||
|
|
6d780ef96d | ||
|
|
b70b94b9b4 | ||
|
|
334453f934 | ||
|
|
5a4ba60072 | ||
|
|
f1fa7d094f | ||
|
|
eed5bc5b04 | ||
|
|
f4b014cb05 | ||
|
|
4418d87664 | ||
|
|
634a473f50 | ||
|
|
ea655b748b | ||
|
|
2d247ddc8b | ||
|
|
c064452aea | ||
|
|
1c6a603042 | ||
|
|
84b0007b05 | ||
|
|
525476ccfa | ||
|
|
b54812cb04 | ||
|
|
ee508cbb3a | ||
|
|
0c61fdee07 | ||
|
|
b9418db31f | ||
|
|
71e7c2171b | ||
|
|
a3e8d5fd38 | ||
|
|
41fd2e2dfc | ||
|
|
2c404c66d6 | ||
|
|
0fa3e77f9a | ||
|
|
5b8e56f590 | ||
|
|
a6d0bd1a19 | ||
|
|
9594e9fb52 | ||
|
|
b01c7b2e56 | ||
|
|
dd4ed7541f | ||
|
|
cc42534a97 | ||
|
|
d203370f01 | ||
|
|
e39742c429 | ||
|
|
301dd81215 | ||
|
|
5416cefd87 | ||
|
|
d41c214d9e | ||
|
|
ec0fcfb48b | ||
|
|
8edc3512c1 | ||
|
|
82d2627c60 | ||
|
|
43e97326e0 | ||
|
|
06770a0702 | ||
|
|
590fb4bc2c | ||
|
|
5db9abc3ce | ||
|
|
c11cc107c8 | ||
|
|
547365894d | ||
|
|
19b5f2889a | ||
|
|
52601f89bd | ||
|
|
72b281b815 | ||
|
|
c0176a5274 | ||
|
|
329d22363f | ||
|
|
87b1443c23 | ||
|
|
29129f8953 | ||
|
|
c97c74a2ee | ||
|
|
1e37c42ea0 | ||
|
|
4c7887fa4e | ||
|
|
2cc5063dee | ||
|
|
e4eda59e6a | ||
|
|
7f918eab6a | ||
|
|
b8c34afef8 | ||
|
|
5738b1bd99 | ||
|
|
2aee75e7d5 | ||
|
|
4d67af51ab | ||
|
|
2f841dfcbc | ||
|
|
7ca69063bd | ||
|
|
085ff75d1d | ||
|
|
21340b2de1 | ||
|
|
a150d38df7 | ||
|
|
02ca36141e | ||
|
|
20bf5e35a4 | ||
|
|
5a67e6b4d6 | ||
|
|
34d1e4b041 | ||
|
|
b88ef858ee | ||
|
|
a363c7a0b1 | ||
|
|
4ac257db0d | ||
|
|
fd5ec01a99 | ||
|
|
bc8c08c468 | ||
|
|
d6648d3337 | ||
|
|
9438bc957f | ||
|
|
5d2219880d | ||
|
|
195da0c9da | ||
|
|
8df0c17bc3 | ||
|
|
7bc9ee0653 | ||
|
|
355f8601c1 | ||
|
|
e7f677b315 | ||
|
|
d7611e74c3 | ||
|
|
d0790dcb22 | ||
|
|
6b4b3924a1 | ||
|
|
2b4f71cc4f | ||
|
|
80359d0854 | ||
|
|
a226a78222 | ||
|
|
42c2ddb355 | ||
|
|
65e7702f90 | ||
|
|
253f419ada | ||
|
|
83818e1097 | ||
|
|
f116a5ed38 | ||
|
|
ee185f56e5 | ||
|
|
b76598ea41 | ||
|
|
432fe48f36 | ||
|
|
7b6e00edd8 | ||
|
|
e59335db4d | ||
|
|
6f00478580 | ||
|
|
4ed4f610f4 | ||
|
|
30fd369bfa | ||
|
|
8208a870a2 | ||
|
|
5eb4a40395 | ||
|
|
95ffc612ca | ||
|
|
afa4a59bb6 | ||
|
|
12515cb0e3 | ||
|
|
0f23335de9 | ||
|
|
bd0764ee0d | ||
|
|
c5c032bd2c | ||
|
|
b717c9739a |
@@ -59,21 +59,30 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: ui/package-lock.json
|
||||
node-version: "18"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: ui/pnpm-lock.yaml
|
||||
|
||||
- name: Install UI dependencies
|
||||
working-directory: ./ui
|
||||
run: npm ci
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run UI format check
|
||||
working-directory: ./ui
|
||||
run: pnpm run format-check
|
||||
|
||||
- name: Run UI linting
|
||||
working-directory: ./ui
|
||||
run: npm run lint
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Run UI build
|
||||
working-directory: ./ui
|
||||
run: npm run build
|
||||
run: pnpm run build
|
||||
|
||||
@@ -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
|
||||
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
import os
|
||||
|
||||
import openai
|
||||
|
||||
client = openai.OpenAI(
|
||||
api_key=os.environ["CASHU_TOKEN"],
|
||||
base_url=os.environ.get("ROUTSTR_API_URL", "https://api.routstr.com/v1"),
|
||||
# base_url="http://roustrjfsdgfiueghsklchg.onion/v1",
|
||||
# client=httpx.AsyncClient(
|
||||
# proxies={"http": "socks5://localhost:9050"},
|
||||
# ), # to use onion proxy (tor)
|
||||
)
|
||||
history: list = []
|
||||
|
||||
|
||||
def chat() -> None:
|
||||
while True:
|
||||
user_msg = {"role": "user", "content": input("\nYou: ")}
|
||||
history.append(user_msg)
|
||||
ai_msg = {"role": "assistant", "content": ""}
|
||||
|
||||
for chunk in client.chat.completions.create(
|
||||
model=os.environ.get("MODEL", "openai/gpt-4o-mini"),
|
||||
messages=history,
|
||||
stream=True,
|
||||
):
|
||||
if len(chunk.choices) > 0:
|
||||
content = chunk.choices[0].delta.content
|
||||
if content is not None:
|
||||
ai_msg["content"] += content
|
||||
print(content, end="", flush=True)
|
||||
print()
|
||||
history.append(ai_msg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
chat()
|
||||
@@ -0,0 +1,11 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Use your Cashu token or API key as the Bearer token,
|
||||
# cashu token is hashed on the server and acts as an Temporary API key
|
||||
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
resp = httpx.get(f"{base_url}/balance/info", headers=headers)
|
||||
print(resp.json())
|
||||
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Send a Cashu token to the /create endpoint to get a persistent API key
|
||||
token = os.environ.get("TOKEN")
|
||||
if not token:
|
||||
print("Please set TOKEN environment variable with a Cashu token")
|
||||
exit(1)
|
||||
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
resp = httpx.get(f"{base_url}/balance/create", params={"initial_balance_token": token})
|
||||
|
||||
print(resp.json())
|
||||
@@ -0,0 +1,12 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Use your Cashu token or API key as the Bearer token
|
||||
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
resp = httpx.post(f"{base_url}/balance/refund", headers=headers)
|
||||
|
||||
print("Refund successful!")
|
||||
print(resp.json())
|
||||
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Use your Cashu token or API key as the Bearer token
|
||||
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
# The Cashu token to top up with
|
||||
cashu_token = input("Enter Cashu token to top up: ")
|
||||
|
||||
resp = httpx.post(
|
||||
f"{base_url}/balance/topup", headers=headers, json={"cashu_token": cashu_token}
|
||||
)
|
||||
|
||||
print(resp.json())
|
||||
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=os.environ.get("MODEL", "gpt-5-nano"),
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)
|
||||
@@ -0,0 +1,45 @@
|
||||
import json
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def create_child_keys(base_url: str, api_key: str, count: int = 3) -> list[str]:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
print(f"Requesting {count} child keys from {base_url}...")
|
||||
|
||||
child_keys = []
|
||||
|
||||
for i in range(count):
|
||||
try:
|
||||
response = httpx.post(f"{base_url}/v1/balance/child-key", headers=headers)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
child_keys.append(data["api_key"])
|
||||
print(
|
||||
f" [{i + 1}] Created: {data['api_key']} (Cost: {data['cost_msats']} msats)"
|
||||
)
|
||||
else:
|
||||
print(f" [{i + 1}] Failed: {response.status_code} - {response.text}")
|
||||
except Exception as e:
|
||||
print(f" [{i + 1}] Error: {str(e)}")
|
||||
|
||||
return child_keys
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python create_child_keys.py <api_key_or_cashu_token> [base_url]")
|
||||
sys.exit(1)
|
||||
|
||||
auth_key = sys.argv[1]
|
||||
base_url = sys.argv[2] if len(sys.argv) > 2 else "http://localhost:8000"
|
||||
|
||||
keys = create_child_keys(base_url, auth_key)
|
||||
|
||||
if keys:
|
||||
print("\nSuccessfully created child keys:")
|
||||
print(json.dumps(keys, indent=2))
|
||||
else:
|
||||
print("\nNo child keys were created.")
|
||||
@@ -0,0 +1,19 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN", ""),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
for model in client.models.list():
|
||||
print(model.id)
|
||||
|
||||
# OR
|
||||
|
||||
models = httpx.get(
|
||||
f"{client.base_url}/v1/models",
|
||||
headers={"Authorization": f"Bearer {client.api_key}"},
|
||||
).json()
|
||||
@@ -0,0 +1,31 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
conversation = [] # type: ignore
|
||||
|
||||
# First turn
|
||||
response1 = client.responses.create( # type: ignore
|
||||
model="o4-mini",
|
||||
input="Hi, my name is Alice.",
|
||||
conversation=conversation,
|
||||
)
|
||||
print("Response 1:", response1.output)
|
||||
|
||||
# Note: The 'conversation' parameter might need to be constructed differently
|
||||
# depending on exact SDK/API spec. Typically, you pass back the previous turn's data.
|
||||
# Assuming the SDK manages or returns a conversation object/ID:
|
||||
# conversation.append(response1)
|
||||
|
||||
# Second turn - demonstrating intent, actual implementation depends on strict API spec
|
||||
# response2 = client.responses.create(
|
||||
# model="openai/gpt-4o-mini",
|
||||
# input="What is my name?",
|
||||
# conversation=conversation,
|
||||
# )
|
||||
# print("Response 2:", response2.output)
|
||||
@@ -0,0 +1,17 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
# The OpenAI SDK handles the 'responses' endpoint if it's updated to the latest version
|
||||
# and the base_url points to a compatible proxy like Routstr.
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
response = client.responses.create(
|
||||
model="gpt-5-mini",
|
||||
input="Tell me a three sentence bedtime story about a unicorn.",
|
||||
)
|
||||
|
||||
print(response.output)
|
||||
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
stream = client.responses.create(
|
||||
model="claude-4.5-sonnet",
|
||||
input="Write a short poem about rust.",
|
||||
stream=True,
|
||||
)
|
||||
|
||||
for event in stream:
|
||||
# Note: Depending on the SDK version and response structure,
|
||||
# you might access event.output_delta or similar fields
|
||||
print(event, end="", flush=True)
|
||||
print()
|
||||
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
response = client.responses.create(
|
||||
model="gpt-5-mini",
|
||||
input="What is the latest news about AI?",
|
||||
tools=[{"type": "web_search"}], # type: ignore
|
||||
)
|
||||
|
||||
print(response.output)
|
||||
@@ -0,0 +1,28 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
messages = []
|
||||
while True:
|
||||
messages.append({"role": "user", "content": input("\nYou: ")})
|
||||
|
||||
stream = client.chat.completions.create(
|
||||
model=os.environ.get("MODEL", "gpt-5.1-mini"),
|
||||
messages=messages, # type: ignore
|
||||
stream=True,
|
||||
)
|
||||
|
||||
print("AI: ", end="")
|
||||
response_content = ""
|
||||
for chunk in stream:
|
||||
if content := chunk.choices[0].delta.content: # type: ignore
|
||||
print(content, end="", flush=True)
|
||||
response_content += content
|
||||
print()
|
||||
|
||||
messages.append({"role": "assistant", "content": response_content})
|
||||
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
# Requires `pip install "httpx[socks]"` and a running Tor proxy on port 9050
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("ONION_URL", "http://roustrjfsdgfiueghsklchg.onion/v1"),
|
||||
http_client=httpx.Client(proxies="socks5://localhost:9050"),
|
||||
)
|
||||
|
||||
print(
|
||||
client.chat.completions.create(
|
||||
model="openai/gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": "Hello from Tor!"}],
|
||||
)
|
||||
.choices[0]
|
||||
.message.content
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
|
||||
Revision ID: a86e5348850b
|
||||
Revises: b9667ffc5701
|
||||
Create Date: 2026-01-10 18:57:48.475781
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a86e5348850b"
|
||||
down_revision = "b9667ffc5701"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Use batch_alter_table for SQLite compatibility
|
||||
with op.batch_alter_table("api_keys", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"parent_key_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True
|
||||
)
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_api_keys_parent_key_hash"), ["parent_key_hash"], unique=False
|
||||
)
|
||||
batch_op.create_foreign_key(
|
||||
"fk_api_keys_parent_key_hash",
|
||||
"api_keys",
|
||||
["parent_key_hash"],
|
||||
["hashed_key"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("api_keys", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("fk_api_keys_parent_key_hash", type_="foreignkey")
|
||||
batch_op.drop_index(batch_op.f("ix_api_keys_parent_key_hash"))
|
||||
batch_op.drop_column("parent_key_hash")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""alias-ids
|
||||
|
||||
Revision ID: b9667ffc5701
|
||||
Revises: lightning_invoices
|
||||
Create Date: 2025-12-25 19:30:44.673350
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b9667ffc5701"
|
||||
down_revision = "lightning_invoices"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic ###
|
||||
op.add_column(
|
||||
"models",
|
||||
sa.Column("canonical_slug", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"models",
|
||||
sa.Column("alias_ids", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("models", "alias_ids")
|
||||
op.drop_column("models", "canonical_slug")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,118 @@
|
||||
"""make upstream provider base_url + api_key unique
|
||||
|
||||
Revision ID: c2d3e4f5a6b7
|
||||
Revises: a86e5348850b
|
||||
Create Date: 2026-01-25 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "c2d3e4f5a6b7"
|
||||
down_revision = "a86e5348850b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _recreate_table_sqlite(add_base_url_unique: bool) -> None:
|
||||
conn = op.get_bind()
|
||||
existing_tables = {
|
||||
row[0]
|
||||
for row in conn.exec_driver_sql(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()
|
||||
}
|
||||
if "upstream_providers_old" in existing_tables:
|
||||
if "upstream_providers" in existing_tables:
|
||||
op.drop_table("upstream_providers_old")
|
||||
else:
|
||||
op.execute(
|
||||
"ALTER TABLE upstream_providers_old RENAME TO upstream_providers"
|
||||
)
|
||||
existing_tables.add("upstream_providers")
|
||||
if "upstream_providers" not in existing_tables:
|
||||
return
|
||||
|
||||
constraints = [
|
||||
sa.UniqueConstraint(
|
||||
"base_url",
|
||||
"api_key",
|
||||
name="uq_upstream_providers_base_url_api_key",
|
||||
)
|
||||
]
|
||||
if add_base_url_unique:
|
||||
constraints.append(
|
||||
sa.UniqueConstraint("base_url", name="uq_upstream_providers_base_url")
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE upstream_providers RENAME TO upstream_providers_old")
|
||||
op.create_table(
|
||||
"upstream_providers",
|
||||
sa.Column(
|
||||
"id", sa.Integer(), primary_key=True, nullable=False, autoincrement=True
|
||||
),
|
||||
sa.Column("provider_type", sa.String(), nullable=False),
|
||||
sa.Column("base_url", sa.String(), nullable=False),
|
||||
sa.Column("api_key", sa.String(), nullable=False),
|
||||
sa.Column("api_version", sa.String(), nullable=True),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("provider_fee", sa.Float(), nullable=False, server_default="1.01"),
|
||||
*constraints,
|
||||
)
|
||||
op.execute(
|
||||
"INSERT INTO upstream_providers (id, provider_type, base_url, api_key, api_version, enabled, provider_fee) "
|
||||
"SELECT id, provider_type, base_url, api_key, api_version, enabled, provider_fee "
|
||||
"FROM upstream_providers_old"
|
||||
)
|
||||
op.drop_table("upstream_providers_old")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name == "sqlite":
|
||||
_recreate_table_sqlite(add_base_url_unique=False)
|
||||
return
|
||||
|
||||
inspector = sa.inspect(conn)
|
||||
for constraint in inspector.get_unique_constraints("upstream_providers"):
|
||||
name = constraint.get("name")
|
||||
if constraint.get("column_names") == ["base_url"] and name:
|
||||
op.drop_constraint(
|
||||
name,
|
||||
"upstream_providers",
|
||||
type_="unique",
|
||||
)
|
||||
index_names = {idx["name"] for idx in inspector.get_indexes("upstream_providers")}
|
||||
if "ix_upstream_providers_base_url" in index_names:
|
||||
op.drop_index("ix_upstream_providers_base_url", table_name="upstream_providers")
|
||||
op.create_unique_constraint(
|
||||
"uq_upstream_providers_base_url_api_key",
|
||||
"upstream_providers",
|
||||
["base_url", "api_key"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name == "sqlite":
|
||||
_recreate_table_sqlite(add_base_url_unique=True)
|
||||
return
|
||||
|
||||
op.drop_constraint(
|
||||
"uq_upstream_providers_base_url_api_key",
|
||||
"upstream_providers",
|
||||
type_="unique",
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"uq_upstream_providers_base_url",
|
||||
"upstream_providers",
|
||||
["base_url"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_upstream_providers_base_url",
|
||||
"upstream_providers",
|
||||
["base_url"],
|
||||
unique=True,
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Add lightning_invoices table
|
||||
|
||||
Revision ID: lightning_invoices
|
||||
Revises: a1a1a1a1a1a1
|
||||
Create Date: 2025-12-10 21:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
revision = "lightning_invoices"
|
||||
down_revision = "a1a1a1a1a1a1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"lightning_invoices",
|
||||
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("bolt11", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("amount_sats", sa.Integer(), nullable=False),
|
||||
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("payment_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("api_key_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("purpose", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("created_at", sa.Integer(), nullable=False),
|
||||
sa.Column("expires_at", sa.Integer(), nullable=False),
|
||||
sa.Column("paid_at", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("bolt11"),
|
||||
sa.UniqueConstraint("payment_hash"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("lightning_invoices")
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "routstr"
|
||||
version = "0.2.1"
|
||||
version = "0.3.0"
|
||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -20,6 +20,7 @@ dependencies = [
|
||||
"nostr>=0.0.2",
|
||||
"mdurl==0.1.2",
|
||||
"pillow>=10",
|
||||
"openai>=1.98.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -72,6 +73,7 @@ packages = ["routstr"]
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
ignore = ["E501"]
|
||||
exclude = ["examples"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
|
||||
+61
-115
@@ -84,105 +84,26 @@ def get_provider_penalty(provider: "BaseUpstreamProvider") -> float:
|
||||
return penalty
|
||||
|
||||
|
||||
def should_prefer_model(
|
||||
candidate_model: "Model",
|
||||
candidate_provider: "BaseUpstreamProvider",
|
||||
current_model: "Model",
|
||||
current_provider: "BaseUpstreamProvider",
|
||||
alias: str,
|
||||
) -> bool:
|
||||
"""Determine if candidate model should replace current model for an alias.
|
||||
|
||||
This is the core decision function for model prioritization. It considers:
|
||||
1. Alias matching quality (exact match vs. canonical slug match)
|
||||
2. Model cost (lower is better)
|
||||
3. Provider penalties (e.g., slight preference against OpenRouter)
|
||||
|
||||
Args:
|
||||
candidate_model: The new model being considered
|
||||
candidate_provider: Provider offering the candidate model
|
||||
current_model: The currently selected model for this alias
|
||||
current_provider: Provider offering the current model
|
||||
alias: The model alias being mapped
|
||||
|
||||
Returns:
|
||||
True if candidate should replace current, False otherwise
|
||||
"""
|
||||
|
||||
def get_base_model_id(model_id: str) -> str:
|
||||
"""Get base model ID by removing provider prefix."""
|
||||
return model_id.split("/", 1)[1] if "/" in model_id else model_id
|
||||
|
||||
def alias_priority(model: "Model") -> int:
|
||||
"""Rank how strong the mapping of alias->model is.
|
||||
|
||||
Highest priority when alias exactly equals the model ID without provider prefix.
|
||||
Next when alias equals canonical slug without prefix. Otherwise lowest.
|
||||
"""
|
||||
model_base = get_base_model_id(model.id)
|
||||
if model_base == alias:
|
||||
return 3
|
||||
if model.canonical_slug:
|
||||
canonical_base = get_base_model_id(model.canonical_slug)
|
||||
if canonical_base == alias:
|
||||
return 2
|
||||
return 1
|
||||
|
||||
candidate_alias_priority = alias_priority(candidate_model)
|
||||
current_alias_priority = alias_priority(current_model)
|
||||
|
||||
# If candidate has better alias match, prefer it regardless of cost
|
||||
if candidate_alias_priority > current_alias_priority:
|
||||
return True
|
||||
|
||||
# If current has better alias match, keep it regardless of cost
|
||||
if current_alias_priority > candidate_alias_priority:
|
||||
return False
|
||||
|
||||
# Same alias priority - compare costs
|
||||
candidate_cost = calculate_model_cost_score(candidate_model)
|
||||
current_cost = calculate_model_cost_score(current_model)
|
||||
|
||||
# Apply provider penalties
|
||||
candidate_adjusted = candidate_cost * get_provider_penalty(candidate_provider)
|
||||
current_adjusted = current_cost * get_provider_penalty(current_provider)
|
||||
|
||||
# Prefer lower adjusted cost
|
||||
should_replace = candidate_adjusted < current_adjusted
|
||||
|
||||
# Log provider changes when candidate wins
|
||||
if should_replace:
|
||||
candidate_provider_name = getattr(
|
||||
candidate_provider, "provider_type", "unknown"
|
||||
)
|
||||
current_provider_name = getattr(current_provider, "provider_type", "unknown")
|
||||
logger.debug(
|
||||
f"Model selection for alias '{alias}': choosing {candidate_provider_name} "
|
||||
f"(cost: ${candidate_adjusted:.6f}) over {current_provider_name} "
|
||||
f"(cost: ${current_adjusted:.6f})"
|
||||
)
|
||||
|
||||
return should_replace
|
||||
|
||||
|
||||
def create_model_mappings(
|
||||
upstreams: list["BaseUpstreamProvider"],
|
||||
overrides_by_id: dict[str, tuple],
|
||||
disabled_model_ids: set[str],
|
||||
) -> tuple[dict[str, "Model"], dict[str, "BaseUpstreamProvider"], dict[str, "Model"]]:
|
||||
) -> tuple[
|
||||
dict[str, "Model"], dict[str, list["BaseUpstreamProvider"]], dict[str, "Model"]
|
||||
]:
|
||||
"""Create optimal model mappings based on cost and provider preferences.
|
||||
|
||||
This is the main entry point for the algorithm. It processes all upstream providers
|
||||
and creates three mappings based on cost optimization:
|
||||
|
||||
1. model_instances: alias -> Model (all model aliases mapped to their Model objects)
|
||||
2. provider_map: alias -> UpstreamProvider (which provider to use for each alias)
|
||||
2. provider_map: alias -> List[UpstreamProvider] (sorted list of providers for each alias)
|
||||
3. unique_models: base_id -> Model (unique models without provider prefixes)
|
||||
|
||||
The algorithm:
|
||||
- Processes non-OpenRouter providers first (they're typically cheaper)
|
||||
- Then processes OpenRouter models (they can still win if cheaper)
|
||||
- For each model alias, uses should_prefer_model() to select the best provider
|
||||
- For each model alias, collects all candidates and sorts them by priority and cost.
|
||||
|
||||
Args:
|
||||
upstreams: List of all upstream provider instances
|
||||
@@ -195,8 +116,7 @@ def create_model_mappings(
|
||||
from .payment.models import _row_to_model
|
||||
from .upstream.helpers import resolve_model_alias
|
||||
|
||||
model_instances: dict[str, "Model"] = {}
|
||||
provider_map: dict[str, "BaseUpstreamProvider"] = {}
|
||||
candidates: dict[str, list[tuple["Model", "BaseUpstreamProvider"]]] = {}
|
||||
unique_models: dict[str, "Model"] = {}
|
||||
|
||||
# Separate OpenRouter from other providers
|
||||
@@ -214,23 +134,14 @@ def create_model_mappings(
|
||||
"""Get base model ID by removing provider prefix."""
|
||||
return model_id.split("/", 1)[1] if "/" in model_id else model_id
|
||||
|
||||
def _maybe_set_alias(
|
||||
def _add_candidate(
|
||||
alias: str, model: "Model", provider: "BaseUpstreamProvider"
|
||||
) -> None:
|
||||
"""Set alias to model/provider if not set or if new model is preferred."""
|
||||
existing_model = model_instances.get(alias)
|
||||
if not existing_model:
|
||||
# No existing mapping, set it
|
||||
model_instances[alias] = model
|
||||
provider_map[alias] = provider
|
||||
else:
|
||||
# Check if candidate should replace existing
|
||||
existing_provider = provider_map[alias]
|
||||
if should_prefer_model(
|
||||
model, provider, existing_model, existing_provider, alias
|
||||
):
|
||||
model_instances[alias] = model
|
||||
provider_map[alias] = provider
|
||||
"""Add candidate model/provider for an alias."""
|
||||
alias_lower = alias.lower()
|
||||
if alias_lower not in candidates:
|
||||
candidates[alias_lower] = []
|
||||
candidates[alias_lower].append((model, provider))
|
||||
|
||||
def process_provider_models(
|
||||
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
|
||||
@@ -254,7 +165,12 @@ def create_model_mappings(
|
||||
# Add to unique models
|
||||
base_id = get_base_model_id(model_to_use.id)
|
||||
if not is_openrouter or base_id not in unique_models:
|
||||
unique_model = model_to_use.copy(update={"id": base_id})
|
||||
unique_model = model_to_use.copy(
|
||||
update={
|
||||
"id": base_id,
|
||||
"upstream_provider_id": upstream.provider_type,
|
||||
}
|
||||
)
|
||||
unique_models[base_id] = unique_model
|
||||
|
||||
# Get all aliases for this model
|
||||
@@ -272,29 +188,59 @@ def create_model_mappings(
|
||||
|
||||
# Try to set each alias
|
||||
for alias in aliases:
|
||||
_maybe_set_alias(alias, model_to_use, upstream)
|
||||
_add_candidate(alias, model_to_use, upstream)
|
||||
|
||||
# Process non-OpenRouter providers first (they're typically cheaper)
|
||||
# Process non-OpenRouter providers first
|
||||
for upstream in other_upstreams:
|
||||
process_provider_models(upstream, is_openrouter=False)
|
||||
|
||||
# Process OpenRouter last - models only win if they're cheaper or better matched
|
||||
# Process OpenRouter last
|
||||
if openrouter:
|
||||
process_provider_models(openrouter, is_openrouter=True)
|
||||
|
||||
# Log provider distribution
|
||||
# Sort candidates and build final maps
|
||||
model_instances: dict[str, "Model"] = {}
|
||||
provider_map: dict[str, list["BaseUpstreamProvider"]] = {}
|
||||
|
||||
def alias_priority(model: "Model", alias: str) -> int:
|
||||
"""Rank how strong the mapping of alias->model is."""
|
||||
model_base = get_base_model_id(model.id)
|
||||
if model_base == alias:
|
||||
return 3
|
||||
if model.canonical_slug:
|
||||
canonical_base = get_base_model_id(model.canonical_slug)
|
||||
if canonical_base == alias:
|
||||
return 2
|
||||
return 1
|
||||
|
||||
for alias, items in candidates.items():
|
||||
# Sort key: (priority DESC, cost ASC)
|
||||
# Using negative cost for DESC sort overall to keep high priority first
|
||||
def sort_key(item: tuple["Model", "BaseUpstreamProvider"]) -> tuple[int, float]:
|
||||
model, provider = item
|
||||
priority = alias_priority(model, alias)
|
||||
cost = calculate_model_cost_score(model)
|
||||
penalty = get_provider_penalty(provider)
|
||||
adjusted_cost = cost * penalty
|
||||
return (priority, -adjusted_cost)
|
||||
|
||||
items.sort(key=sort_key, reverse=True)
|
||||
|
||||
best_model, best_provider = items[0]
|
||||
model_instances[alias] = best_model
|
||||
provider_map[alias] = [p for _, p in items]
|
||||
|
||||
# Log provider distribution (using top provider for stats)
|
||||
provider_counts: dict[str, int] = {}
|
||||
for provider in provider_map.values():
|
||||
provider_name = getattr(provider, "upstream_name", "unknown")
|
||||
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
|
||||
for providers in provider_map.values():
|
||||
if providers:
|
||||
provider = providers[0]
|
||||
provider_name = getattr(provider, "upstream_name", "unknown")
|
||||
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
|
||||
|
||||
logger.debug(
|
||||
"Created model mappings",
|
||||
extra={
|
||||
"unique_model_count": len(unique_models),
|
||||
"total_alias_count": len(model_instances),
|
||||
"provider_distribution": provider_counts,
|
||||
},
|
||||
f"Updated model mappings with ({len(unique_models)} unique models and {len(model_instances)} aliases)",
|
||||
extra={"provider_distribution": provider_counts},
|
||||
)
|
||||
|
||||
return model_instances, provider_map, unique_models
|
||||
|
||||
+212
-56
@@ -286,30 +286,55 @@ async def validate_bearer_key(
|
||||
)
|
||||
|
||||
|
||||
async def get_billing_key(key: ApiKey, session: AsyncSession) -> ApiKey:
|
||||
"""Returns the key that should be charged for the request."""
|
||||
if key.parent_key_hash:
|
||||
parent = await session.get(ApiKey, key.parent_key_hash)
|
||||
if parent:
|
||||
# We want to keep the total_requests and total_spent on the child key
|
||||
# but use the balance and reserved_balance of the parent.
|
||||
# However, pay_for_request updates reserved_balance and total_requests.
|
||||
# To stay simple, we charge the parent's balance and update parent's total_requests.
|
||||
return parent
|
||||
else:
|
||||
logger.error(
|
||||
"Parent key not found for child key",
|
||||
extra={
|
||||
"child_key_hash": key.hashed_key[:8] + "...",
|
||||
"parent_key_hash": key.parent_key_hash[:8] + "...",
|
||||
},
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
async def pay_for_request(
|
||||
key: ApiKey, cost_per_request: int, session: AsyncSession
|
||||
) -> int:
|
||||
"""Process payment for a request."""
|
||||
|
||||
billing_key = await get_billing_key(key, session)
|
||||
|
||||
logger.info(
|
||||
"Processing payment for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"current_balance": key.balance,
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"current_balance": billing_key.balance,
|
||||
"required_cost": cost_per_request,
|
||||
"sufficient_balance": key.balance >= cost_per_request,
|
||||
"sufficient_balance": billing_key.balance >= cost_per_request,
|
||||
},
|
||||
)
|
||||
|
||||
if key.total_balance < cost_per_request:
|
||||
if billing_key.total_balance < cost_per_request:
|
||||
logger.warning(
|
||||
"Insufficient balance for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"balance": key.balance,
|
||||
"reserved_balance": key.reserved_balance,
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"balance": billing_key.balance,
|
||||
"reserved_balance": billing_key.reserved_balance,
|
||||
"required": cost_per_request,
|
||||
"shortfall": cost_per_request - key.total_balance,
|
||||
"shortfall": cost_per_request - billing_key.total_balance,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -317,7 +342,7 @@ async def pay_for_request(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.total_balance} available. (reserved: {key.reserved_balance})",
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {billing_key.total_balance} available. (reserved: {billing_key.reserved_balance})",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
@@ -328,22 +353,33 @@ async def pay_for_request(
|
||||
"Charging base cost for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"cost": cost_per_request,
|
||||
"balance_before": key.balance,
|
||||
"balance_before": billing_key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
# Charge the base cost for the request atomically to avoid race conditions
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.balance) >= cost_per_request)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= cost_per_request)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
||||
total_requests=col(ApiKey.total_requests) + 1,
|
||||
)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also increment total_requests on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(total_requests=col(ApiKey.total_requests) + 1)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
@@ -351,8 +387,9 @@ async def pay_for_request(
|
||||
"Concurrent request depleted balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"required_cost": cost_per_request,
|
||||
"current_balance": key.balance,
|
||||
"current_balance": billing_key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -361,23 +398,26 @@ async def pay_for_request(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {billing_key.balance} available.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await session.refresh(key)
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Payment processed successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"charged_amount": cost_per_request,
|
||||
"new_balance": key.balance,
|
||||
"total_spent": key.total_spent,
|
||||
"total_requests": key.total_requests,
|
||||
"new_balance": billing_key.balance,
|
||||
"total_spent": billing_key.total_spent,
|
||||
"total_requests": billing_key.total_requests,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -387,9 +427,11 @@ async def pay_for_request(
|
||||
async def revert_pay_for_request(
|
||||
key: ApiKey, session: AsyncSession, cost_per_request: int
|
||||
) -> None:
|
||||
billing_key = await get_billing_key(key, session)
|
||||
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||
total_requests=col(ApiKey.total_requests) - 1,
|
||||
@@ -397,27 +439,40 @@ async def revert_pay_for_request(
|
||||
)
|
||||
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also decrement total_requests on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(total_requests=col(ApiKey.total_requests) - 1)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to revert payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"cost_to_revert": cost_per_request,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
"current_reserved_balance": billing_key.reserved_balance,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"failed to revert request payment: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"message": f"failed to revert request payment: {cost_per_request} mSats required. {billing_key.balance} available.",
|
||||
"type": "payment_error",
|
||||
"code": "payment_error",
|
||||
}
|
||||
},
|
||||
)
|
||||
await session.refresh(key)
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
|
||||
|
||||
async def adjust_payment_for_tokens(
|
||||
@@ -428,25 +483,58 @@ async def adjust_payment_for_tokens(
|
||||
This is called after the initial payment and the upstream request is complete.
|
||||
Returns cost data to be included in the response.
|
||||
"""
|
||||
billing_key = await get_billing_key(key, session)
|
||||
model = response_data.get("model", "unknown")
|
||||
|
||||
logger.debug(
|
||||
"Starting payment adjustment for tokens",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_balance": key.balance,
|
||||
"current_balance": billing_key.balance,
|
||||
"has_usage": "usage" in response_data,
|
||||
},
|
||||
)
|
||||
|
||||
async def release_reservation_only() -> None:
|
||||
"""Fallback to release reservation without charging when main update fails."""
|
||||
try:
|
||||
release_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost
|
||||
)
|
||||
)
|
||||
await session.exec(release_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
logger.warning(
|
||||
"Released reservation without charging (fallback)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to release reservation in fallback",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
match await calculate_cost(response_data, deducted_max_cost, session):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
"Using max cost data (no token adjustment)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"max_cost": cost.total_msats,
|
||||
},
|
||||
@@ -454,7 +542,7 @@ async def adjust_payment_for_tokens(
|
||||
# Finalize by releasing reservation and charging max cost
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
|
||||
balance=col(ApiKey.balance) - cost.total_msats,
|
||||
@@ -462,26 +550,41 @@ async def adjust_payment_for_tokens(
|
||||
)
|
||||
)
|
||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also update total_spent on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(total_spent=col(ApiKey.total_spent) + cost.total_msats)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to finalize max-cost payment - insufficient reserved balance",
|
||||
"Failed to finalize max-cost payment - retrying reservation release",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
"current_reserved_balance": billing_key.reserved_balance,
|
||||
"total_cost": cost.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
await release_reservation_only()
|
||||
else:
|
||||
await session.refresh(key)
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
logger.info(
|
||||
"Max cost payment finalized",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"charged_amount": cost.total_msats,
|
||||
"new_balance": key.balance,
|
||||
"new_balance": billing_key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
@@ -497,6 +600,7 @@ async def adjust_payment_for_tokens(
|
||||
"Calculated token-based cost",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"token_cost": cost.total_msats,
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
@@ -509,11 +613,15 @@ async def adjust_payment_for_tokens(
|
||||
if cost_difference == 0:
|
||||
logger.debug(
|
||||
"Finalizing with exact reserved cost",
|
||||
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
@@ -522,8 +630,20 @@ async def adjust_payment_for_tokens(
|
||||
)
|
||||
)
|
||||
await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also update total_spent on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
return cost.dict()
|
||||
|
||||
# this should never happen why do we handle this???
|
||||
@@ -533,16 +653,17 @@ async def adjust_payment_for_tokens(
|
||||
"Additional charge required for token usage",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"additional_charge": cost_difference,
|
||||
"current_balance": key.balance,
|
||||
"sufficient_balance": key.balance >= cost_difference,
|
||||
"current_balance": billing_key.balance,
|
||||
"sufficient_balance": billing_key.balance >= cost_difference,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
@@ -551,30 +672,45 @@ async def adjust_payment_for_tokens(
|
||||
)
|
||||
)
|
||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also update total_spent on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount:
|
||||
cost.total_msats = total_cost_msats
|
||||
await session.refresh(key)
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Finalized payment with additional charge",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"charged_amount": total_cost_msats,
|
||||
"new_balance": key.balance,
|
||||
"new_balance": billing_key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to finalize additional charge (concurrent operation)",
|
||||
"Failed to finalize additional charge - releasing reservation",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"attempted_charge": total_cost_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
await release_reservation_only()
|
||||
else:
|
||||
# Refund some of the base cost
|
||||
refund = abs(cost_difference)
|
||||
@@ -582,15 +718,16 @@ async def adjust_payment_for_tokens(
|
||||
"Refunding excess payment",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"refund_amount": refund,
|
||||
"current_balance": key.balance,
|
||||
"current_balance": billing_key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
refund_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
@@ -599,41 +736,54 @@ async def adjust_payment_for_tokens(
|
||||
)
|
||||
)
|
||||
result = await session.exec(refund_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also update total_spent on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to finalize payment - insufficient reserved balance",
|
||||
"Failed to finalize payment - releasing reservation",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
"current_reserved_balance": billing_key.reserved_balance,
|
||||
"total_cost": total_cost_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
# Still return the cost data even if we couldn't properly finalize
|
||||
# The reservation was already made, so the user has paid
|
||||
await release_reservation_only()
|
||||
else:
|
||||
cost.total_msats = total_cost_msats
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
|
||||
cost.total_msats = total_cost_msats
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Refund processed successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"refunded_amount": refund,
|
||||
"new_balance": key.balance,
|
||||
"final_cost": cost.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"Refund processed successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"refunded_amount": refund,
|
||||
"new_balance": billing_key.balance,
|
||||
"final_cost": cost.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
return cost.dict()
|
||||
|
||||
case CostDataError() as error:
|
||||
logger.error(
|
||||
"Cost calculation error during payment adjustment",
|
||||
"Cost calculation error during payment adjustment - releasing reservation",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
@@ -641,6 +791,7 @@ async def adjust_payment_for_tokens(
|
||||
"error_code": error.code,
|
||||
},
|
||||
)
|
||||
await release_reservation_only()
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -652,7 +803,12 @@ async def adjust_payment_for_tokens(
|
||||
}
|
||||
},
|
||||
)
|
||||
# Fallback return to satisfy type checker; execution should not reach here
|
||||
# Fallback: should not reach here, but release reservation just in case
|
||||
logger.error(
|
||||
"Unexpected fallback in adjust_payment_for_tokens - releasing reservation",
|
||||
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
|
||||
)
|
||||
await release_reservation_only()
|
||||
return {
|
||||
"base_msats": deducted_max_cost,
|
||||
"input_msats": 0,
|
||||
|
||||
+109
-13
@@ -10,6 +10,7 @@ from .auth import validate_bearer_key
|
||||
from .core.db import ApiKey, AsyncSession, get_session
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
from .lightning import lightning_router
|
||||
from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token
|
||||
|
||||
router = APIRouter()
|
||||
@@ -31,16 +32,30 @@ async def get_key_from_header(
|
||||
)
|
||||
|
||||
|
||||
# TODO: remove this endpoint when frontend is updated
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
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 {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": key.balance,
|
||||
"reserved": key.reserved_balance,
|
||||
"balance": billing_key.balance,
|
||||
"reserved": billing_key.reserved_balance,
|
||||
"is_child": key.parent_key_hash is not None,
|
||||
"parent_key": "sk-" + key.parent_key_hash if key.parent_key_hash else None,
|
||||
"total_requests": key.total_requests,
|
||||
"total_spent": key.total_spent,
|
||||
}
|
||||
|
||||
|
||||
# TODO: remove this endpoint when frontend is updated
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def account_info(
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
return await get_balance_info(key, session)
|
||||
|
||||
|
||||
# TODO: Implement POST /v1/wallet/create endpoint
|
||||
# This endpoint should accept:
|
||||
# - cashu_token (required): The eCash token to deposit
|
||||
@@ -65,12 +80,11 @@ async def create_balance(
|
||||
|
||||
|
||||
@router.get("/info")
|
||||
async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
return {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": key.balance,
|
||||
"reserved": key.reserved_balance,
|
||||
}
|
||||
async def wallet_info(
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
return await get_balance_info(key, session)
|
||||
|
||||
|
||||
class TopupRequest(BaseModel):
|
||||
@@ -84,6 +98,10 @@ 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:
|
||||
cashu_token = topup_request.cashu_token
|
||||
if cashu_token is None:
|
||||
@@ -93,7 +111,7 @@ async def topup_wallet_endpoint(
|
||||
if len(cashu_token) < 10 or "cashu" not in cashu_token:
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
try:
|
||||
amount_msats = await credit_balance(cashu_token, key, session)
|
||||
amount_msats = await credit_balance(cashu_token, billing_key, session)
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
if "already spent" in error_msg.lower():
|
||||
@@ -153,7 +171,14 @@ async def refund_wallet_endpoint(
|
||||
return cached
|
||||
|
||||
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
||||
remaining_balance_msats: int = key.balance
|
||||
|
||||
if key.parent_key_hash:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot refund child key. Please refund the parent key instead.",
|
||||
)
|
||||
|
||||
remaining_balance_msats: int = key.total_balance
|
||||
|
||||
if key.refund_currency == "sat":
|
||||
remaining_balance = remaining_balance_msats // 1000
|
||||
@@ -226,6 +251,75 @@ 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 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(
|
||||
status_code=400,
|
||||
detail="Cannot create a child key for another child key.",
|
||||
)
|
||||
|
||||
cost_per_key = settings.child_key_cost
|
||||
total_cost = cost_per_key * count
|
||||
|
||||
if key.total_balance < total_cost:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
|
||||
)
|
||||
|
||||
# Deduct cost from parent
|
||||
key.balance -= total_cost
|
||||
key.total_spent += total_cost
|
||||
session.add(key)
|
||||
|
||||
# Generate new keys
|
||||
import secrets
|
||||
|
||||
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)
|
||||
|
||||
await session.commit()
|
||||
|
||||
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(
|
||||
"/{path:path}",
|
||||
methods=["GET", "POST", "PUT", "DELETE"],
|
||||
@@ -238,6 +332,8 @@ async def wallet_catch_all(path: str) -> NoReturn:
|
||||
)
|
||||
|
||||
|
||||
balance_router.include_router(lightning_router)
|
||||
balance_router.include_router(router)
|
||||
|
||||
deprecated_wallet_router = APIRouter(prefix="/v1/wallet", include_in_schema=False)
|
||||
deprecated_wallet_router.include_router(router)
|
||||
|
||||
+292
-119
@@ -124,7 +124,7 @@ async def partial_apikeys(request: Request) -> str:
|
||||
|
||||
rows = "".join(
|
||||
[
|
||||
f"<tr><td>{key.hashed_key}</td><td>{key.balance}</td><td>{key.total_spent}</td><td>{key.total_requests}</td><td>{key.refund_address}</td><td>{fmt_time(key.key_expiry_time)}</td></tr>"
|
||||
f"<tr><td>{key.hashed_key}{' <br><small>(Child of ' + key.parent_key_hash[:8] + '...)</small>' if key.parent_key_hash else ''}</td><td>{key.balance}</td><td>{key.total_spent}</td><td>{key.total_requests}</td><td>{key.refund_address}</td><td>{fmt_time(key.key_expiry_time)}</td></tr>"
|
||||
for key in api_keys
|
||||
]
|
||||
)
|
||||
@@ -158,6 +158,7 @@ async def get_temporary_balances_api(request: Request) -> list[dict[str, object]
|
||||
"total_requests": key.total_requests,
|
||||
"refund_address": key.refund_address,
|
||||
"key_expiry_time": key.key_expiry_time,
|
||||
"parent_key_hash": key.parent_key_hash,
|
||||
}
|
||||
for key in api_keys
|
||||
]
|
||||
@@ -1493,20 +1494,8 @@ class ModelCreate(BaseModel):
|
||||
per_request_limits: dict[str, object] | None = None
|
||||
top_provider: dict[str, object] | None = None
|
||||
upstream_provider_id: int | None = None
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ModelUpdate(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
created: int
|
||||
context_length: int
|
||||
architecture: dict[str, object]
|
||||
pricing: dict[str, object]
|
||||
per_request_limits: dict[str, object] | None = None
|
||||
top_provider: dict[str, object] | None = None
|
||||
upstream_provider_id: int | None = None
|
||||
canonical_slug: str | None = None
|
||||
alias_ids: list[str] | None = None
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@@ -2416,44 +2405,80 @@ async def admin_upstream_providers(request: Request) -> str:
|
||||
"/api/upstream-providers/{provider_id}/models",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def create_provider_model(
|
||||
async def upsert_provider_model(
|
||||
provider_id: int, payload: ModelCreate
|
||||
) -> dict[str, object]:
|
||||
print(payload)
|
||||
logger.info(
|
||||
f"UPSERT_PROVIDER_MODEL called: provider_id={provider_id}, model_id={payload.id}"
|
||||
)
|
||||
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")
|
||||
|
||||
exists = await session.get(ModelRow, (payload.id, provider_id))
|
||||
if exists:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Model with this ID already exists for this provider",
|
||||
)
|
||||
# Try to get existing model
|
||||
existing_row = await session.get(ModelRow, (payload.id, provider_id))
|
||||
|
||||
row = ModelRow(
|
||||
id=payload.id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
created=int(payload.created),
|
||||
context_length=int(payload.context_length),
|
||||
architecture=json.dumps(payload.architecture),
|
||||
pricing=json.dumps(payload.pricing),
|
||||
sats_pricing=None,
|
||||
per_request_limits=(
|
||||
if existing_row:
|
||||
# Update existing model
|
||||
logger.info(f"Updating existing model: {payload.id}")
|
||||
existing_row.name = payload.name
|
||||
existing_row.description = payload.description
|
||||
existing_row.created = int(payload.created)
|
||||
existing_row.context_length = int(payload.context_length)
|
||||
existing_row.architecture = json.dumps(payload.architecture)
|
||||
existing_row.pricing = json.dumps(payload.pricing)
|
||||
existing_row.sats_pricing = None
|
||||
existing_row.per_request_limits = (
|
||||
json.dumps(payload.per_request_limits)
|
||||
if payload.per_request_limits is not None
|
||||
else None
|
||||
),
|
||||
top_provider=(
|
||||
)
|
||||
existing_row.top_provider = (
|
||||
json.dumps(payload.top_provider) if payload.top_provider else None
|
||||
),
|
||||
upstream_provider_id=provider_id,
|
||||
enabled=payload.enabled,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
)
|
||||
existing_row.canonical_slug = payload.canonical_slug
|
||||
existing_row.alias_ids = (
|
||||
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
||||
)
|
||||
existing_row.enabled = payload.enabled
|
||||
|
||||
session.add(existing_row)
|
||||
await session.commit()
|
||||
await session.refresh(existing_row)
|
||||
row = existing_row
|
||||
|
||||
else:
|
||||
# Create new model
|
||||
logger.info(f"Creating new model: {payload.id}")
|
||||
row = ModelRow(
|
||||
id=payload.id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
created=int(payload.created),
|
||||
context_length=int(payload.context_length),
|
||||
architecture=json.dumps(payload.architecture),
|
||||
pricing=json.dumps(payload.pricing),
|
||||
sats_pricing=None,
|
||||
per_request_limits=(
|
||||
json.dumps(payload.per_request_limits)
|
||||
if payload.per_request_limits is not None
|
||||
else None
|
||||
),
|
||||
top_provider=(
|
||||
json.dumps(payload.top_provider) if payload.top_provider else None
|
||||
),
|
||||
canonical_slug=payload.canonical_slug,
|
||||
alias_ids=(
|
||||
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
||||
),
|
||||
upstream_provider_id=provider_id,
|
||||
enabled=payload.enabled,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
|
||||
await refresh_model_maps()
|
||||
return _row_to_model(
|
||||
@@ -2461,6 +2486,20 @@ async def create_provider_model(
|
||||
).dict() # type: ignore
|
||||
|
||||
|
||||
@admin_router.patch(
|
||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def update_provider_model_legacy(
|
||||
provider_id: int, model_id: str, payload: ModelCreate
|
||||
) -> dict[str, object]:
|
||||
"""Legacy PATCH endpoint - redirects to upsert POST endpoint for backward compatibility."""
|
||||
logger.info(
|
||||
f"LEGACY_PATCH_UPDATE called: provider_id={provider_id}, model_id={model_id}"
|
||||
)
|
||||
return await upsert_provider_model(provider_id, payload)
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
@@ -2477,80 +2516,10 @@ 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
|
||||
|
||||
|
||||
@admin_router.patch(
|
||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def update_provider_model(
|
||||
provider_id: int, model_id: str, payload: ModelUpdate
|
||||
) -> dict[str, object]:
|
||||
if payload.id != model_id:
|
||||
raise HTTPException(status_code=400, detail="Path id does not match payload id")
|
||||
|
||||
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")
|
||||
|
||||
row = await session.get(ModelRow, (model_id, provider_id))
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Model not found for this provider"
|
||||
)
|
||||
|
||||
row.name = payload.name
|
||||
row.description = payload.description
|
||||
row.created = int(payload.created)
|
||||
row.context_length = int(payload.context_length)
|
||||
row.architecture = json.dumps(payload.architecture)
|
||||
row.pricing = json.dumps(payload.pricing)
|
||||
row.sats_pricing = None
|
||||
row.per_request_limits = (
|
||||
json.dumps(payload.per_request_limits)
|
||||
if payload.per_request_limits is not None
|
||||
else None
|
||||
)
|
||||
row.top_provider = (
|
||||
json.dumps(payload.top_provider) if payload.top_provider else None
|
||||
)
|
||||
was_disabled = not row.enabled
|
||||
row.enabled = payload.enabled
|
||||
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
|
||||
if was_disabled and payload.enabled:
|
||||
from ..payment.models import _cleanup_enabled_models_once
|
||||
|
||||
try:
|
||||
await _cleanup_enabled_models_once()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to run model cleanup after enabling: {e}",
|
||||
extra={"model_id": model_id, "error": str(e)},
|
||||
)
|
||||
|
||||
await refresh_model_maps()
|
||||
return _row_to_model(
|
||||
row, apply_provider_fee=True, provider_fee=provider.provider_fee
|
||||
).dict() # type: ignore
|
||||
|
||||
|
||||
@admin_router.put(
|
||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def update_provider_model_put(
|
||||
provider_id: int, model_id: str, payload: ModelUpdate
|
||||
) -> dict[str, object]:
|
||||
return await update_provider_model(provider_id, model_id, payload)
|
||||
|
||||
|
||||
@admin_router.delete(
|
||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
@@ -2629,12 +2598,14 @@ async def create_upstream_provider(
|
||||
async with create_session() as session:
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == payload.base_url
|
||||
UpstreamProviderRow.base_url == payload.base_url,
|
||||
UpstreamProviderRow.api_key == payload.api_key,
|
||||
)
|
||||
)
|
||||
if result.first():
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Provider with this base URL already exists"
|
||||
status_code=409,
|
||||
detail="Provider with this base URL and API key already exists",
|
||||
)
|
||||
|
||||
provider = UpstreamProviderRow(
|
||||
@@ -2758,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 = []
|
||||
@@ -2766,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}"
|
||||
@@ -2791,6 +2762,186 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
class CreateAccountRequest(BaseModel):
|
||||
provider_type: str
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/create-account",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def create_provider_account_by_type(
|
||||
payload: CreateAccountRequest,
|
||||
) -> dict[str, object]:
|
||||
"""Create a new account with a provider by provider type (before provider exists in DB)."""
|
||||
from ..upstream import upstream_provider_classes
|
||||
|
||||
provider_class = next(
|
||||
(
|
||||
cls
|
||||
for cls in upstream_provider_classes
|
||||
if cls.provider_type == payload.provider_type
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not provider_class:
|
||||
raise HTTPException(status_code=404, detail="Provider type not found")
|
||||
|
||||
try:
|
||||
account_data = await provider_class.create_account_static()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"account_data": account_data,
|
||||
"message": "Account created successfully",
|
||||
}
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider does not support account creation: {str(e)}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to create account for provider type {payload.provider_type}: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
class TopupRequest(BaseModel):
|
||||
amount: int
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/{provider_id}/topup",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def initiate_provider_topup(
|
||||
provider_id: int, payload: TopupRequest
|
||||
) -> dict[str, object]:
|
||||
"""Initiate a Lightning Network top-up for the upstream provider account."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
|
||||
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")
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"Initiating top-up for provider {provider_id}",
|
||||
extra={"amount": payload.amount},
|
||||
)
|
||||
topup_data = await upstream_instance.initiate_topup(payload.amount)
|
||||
logger.info(
|
||||
"Top-up initiated successfully",
|
||||
extra={
|
||||
"provider_id": provider_id,
|
||||
"invoice_id": topup_data.invoice_id,
|
||||
"amount": topup_data.amount,
|
||||
},
|
||||
)
|
||||
|
||||
response_data = {
|
||||
"ok": True,
|
||||
"topup_data": {
|
||||
"invoice_id": topup_data.invoice_id,
|
||||
"payment_request": topup_data.payment_request,
|
||||
"amount": topup_data.amount,
|
||||
"currency": topup_data.currency,
|
||||
"expires_at": topup_data.expires_at,
|
||||
"checkout_url": topup_data.checkout_url,
|
||||
},
|
||||
"message": "Top-up initiated successfully",
|
||||
}
|
||||
logger.info("Returning response", extra={"response": response_data})
|
||||
return response_data
|
||||
except NotImplementedError as e:
|
||||
logger.error(f"Provider does not support top-up: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Provider does not support top-up: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to initiate top-up for provider {provider_id}: {e}",
|
||||
extra={"error_type": type(e).__name__, "error": str(e)},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/upstream-providers/{provider_id}/topup/{invoice_id}/status",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, object]:
|
||||
"""Check the status of a Lightning Network top-up invoice."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
from ..upstream.ppqai import PPQAIUpstreamProvider
|
||||
|
||||
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")
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
if not isinstance(upstream_instance, PPQAIUpstreamProvider):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Provider does not support top-up status checking",
|
||||
)
|
||||
|
||||
try:
|
||||
paid = await upstream_instance.check_topup_status(invoice_id)
|
||||
return {"ok": True, "paid": paid}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to check top-up status for provider {provider_id}: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/upstream-providers/{provider_id}/balance",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def get_provider_balance(provider_id: int) -> dict[str, object]:
|
||||
"""Get the current account balance for the upstream provider."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
|
||||
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")
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
try:
|
||||
balance_data = await upstream_instance.get_balance()
|
||||
return {"ok": True, "balance_data": balance_data}
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider does not support balance checking: {str(e)}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch balance for provider {provider_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/openrouter-presets",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
@@ -2932,6 +3083,9 @@ async def get_logs_api(
|
||||
level: str | None = None,
|
||||
request_id: str | None = None,
|
||||
search: str | None = None,
|
||||
status_codes: str | None = Query(None, description="Comma-separated status codes"),
|
||||
methods: str | None = Query(None, description="Comma-separated HTTP methods"),
|
||||
endpoints: str | None = Query(None, description="Comma-separated endpoints"),
|
||||
limit: int = 100,
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
@@ -2942,16 +3096,32 @@ async def get_logs_api(
|
||||
level: Filter by log level
|
||||
request_id: Filter by request ID
|
||||
search: Search text in message and name fields (case-insensitive)
|
||||
status_codes: Comma-separated list of HTTP status codes
|
||||
methods: Comma-separated list of HTTP methods
|
||||
endpoints: Comma-separated list of endpoints
|
||||
limit: Maximum number of entries to return
|
||||
|
||||
Returns:
|
||||
Dict containing logs and filter metadata
|
||||
"""
|
||||
status_code_list = None
|
||||
if status_codes:
|
||||
try:
|
||||
status_code_list = [int(s.strip()) for s in status_codes.split(",")]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
method_list = [m.strip() for m in methods.split(",")] if methods else None
|
||||
endpoint_list = [e.strip() for e in endpoints.split(",")] if endpoints else None
|
||||
|
||||
log_entries = log_manager.search_logs(
|
||||
date=date,
|
||||
level=level,
|
||||
request_id=request_id,
|
||||
search_text=search,
|
||||
status_codes=status_code_list,
|
||||
methods=method_list,
|
||||
endpoints=endpoint_list,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@@ -2962,6 +3132,9 @@ async def get_logs_api(
|
||||
"level": level,
|
||||
"request_id": request_id,
|
||||
"search": search,
|
||||
"status_codes": status_codes,
|
||||
"methods": methods,
|
||||
"endpoints": endpoints,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
|
||||
+44
-5
@@ -1,11 +1,13 @@
|
||||
import os
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
||||
from sqlmodel import Field, Relationship, SQLModel, func, select
|
||||
from sqlmodel import Field, Relationship, SQLModel, func, select, update
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .logging import get_logger
|
||||
@@ -46,12 +48,23 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
||||
default=None,
|
||||
description="Currency of the cashu-token",
|
||||
)
|
||||
parent_key_hash: str | None = Field(
|
||||
default=None, foreign_key="api_keys.hashed_key", index=True
|
||||
)
|
||||
|
||||
@property
|
||||
def total_balance(self) -> int:
|
||||
return self.balance - self.reserved_balance
|
||||
|
||||
|
||||
async def reset_all_reserved_balances(session: AsyncSession) -> None:
|
||||
logger.info("Resetting all reserved balances to 0")
|
||||
stmt = update(ApiKey).values(reserved_balance=0)
|
||||
await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
logger.info("Reserved balances reset successfully")
|
||||
|
||||
|
||||
class ModelRow(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "models"
|
||||
id: str = Field(primary_key=True)
|
||||
@@ -67,17 +80,46 @@ class ModelRow(SQLModel, table=True): # type: ignore
|
||||
sats_pricing: str | None = Field(default=None)
|
||||
per_request_limits: str | None = Field(default=None)
|
||||
top_provider: str | None = Field(default=None)
|
||||
canonical_slug: str | None = Field(default=None, description="Canonical model slug")
|
||||
alias_ids: str | None = Field(
|
||||
default=None, description="JSON array of model alias IDs"
|
||||
)
|
||||
enabled: bool = Field(default=True, description="Whether this model is enabled")
|
||||
upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models")
|
||||
|
||||
|
||||
class LightningInvoice(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "lightning_invoices"
|
||||
|
||||
id: str = Field(primary_key=True, description="Unique invoice identifier")
|
||||
bolt11: str = Field(description="BOLT11 invoice string", unique=True)
|
||||
amount_sats: int = Field(description="Amount in satoshis")
|
||||
description: str = Field(description="Invoice description")
|
||||
payment_hash: str = Field(description="Payment hash for tracking", unique=True)
|
||||
status: str = Field(
|
||||
default="pending", description="pending, paid, expired, cancelled"
|
||||
)
|
||||
api_key_hash: str | None = Field(
|
||||
default=None, description="Associated API key hash for topup operations"
|
||||
)
|
||||
purpose: str = Field(description="create or topup")
|
||||
created_at: int = Field(
|
||||
default_factory=lambda: int(time.time()), description="Unix timestamp"
|
||||
)
|
||||
expires_at: int = Field(description="Unix timestamp when invoice expires")
|
||||
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
|
||||
|
||||
|
||||
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "upstream_providers"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("base_url", "api_key", name="uq_upstream_providers_base_url_api_key"),
|
||||
)
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
provider_type: str = Field(
|
||||
description="Provider type: custom, openai, anthropic, azure, openrouter, etc."
|
||||
)
|
||||
base_url: str = Field(unique=True, description="Base URL of the upstream API")
|
||||
base_url: str = Field(description="Base URL of the upstream API")
|
||||
api_key: str = Field(description="API key for the upstream provider")
|
||||
api_version: str | None = Field(
|
||||
default=None, description="API version for Azure OpenAI"
|
||||
@@ -126,8 +168,6 @@ def run_migrations() -> None:
|
||||
import pathlib
|
||||
|
||||
try:
|
||||
logger.info("Starting database migrations")
|
||||
|
||||
# Get the path to the alembic.ini file
|
||||
project_root = pathlib.Path(__file__).resolve().parents[2]
|
||||
alembic_ini_path = project_root / "alembic.ini"
|
||||
@@ -144,7 +184,6 @@ def run_migrations() -> None:
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
# Run migrations to the latest revision
|
||||
logger.info("Running migrations to latest revision")
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
|
||||
logger.info("Database migrations completed successfully")
|
||||
|
||||
@@ -6,6 +6,15 @@ from .logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class UpstreamError(Exception):
|
||||
"""Exception raised when an upstream provider fails."""
|
||||
|
||||
def __init__(self, message: str, status_code: int = 502):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Handle HTTP exceptions and include request ID in response."""
|
||||
request_id = getattr(request.state, "request_id", "unknown")
|
||||
|
||||
@@ -105,6 +105,9 @@ class LogManager:
|
||||
level: str | None = None,
|
||||
request_id: str | None = None,
|
||||
search_text: str | None = None,
|
||||
status_codes: list[int] | None = None,
|
||||
methods: list[str] | None = None,
|
||||
endpoints: list[str] | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
@@ -134,7 +137,13 @@ class LogManager:
|
||||
|
||||
for log_data in iterator:
|
||||
if not self._matches_filters(
|
||||
log_data, level, request_id, search_text_lower
|
||||
log_data,
|
||||
level,
|
||||
request_id,
|
||||
search_text_lower,
|
||||
status_codes,
|
||||
methods,
|
||||
endpoints,
|
||||
):
|
||||
continue
|
||||
|
||||
@@ -153,6 +162,9 @@ class LogManager:
|
||||
level: str | None,
|
||||
request_id: str | None,
|
||||
search_text_lower: str | None,
|
||||
status_codes: list[int] | None = None,
|
||||
methods: list[str] | None = None,
|
||||
endpoints: list[str] | None = None,
|
||||
) -> bool:
|
||||
if level and log_data.get("levelname", "").upper() != level.upper():
|
||||
return False
|
||||
@@ -160,6 +172,36 @@ class LogManager:
|
||||
if request_id and log_data.get("request_id") != request_id:
|
||||
return False
|
||||
|
||||
if status_codes:
|
||||
entry_status = log_data.get("status_code")
|
||||
if entry_status is not None:
|
||||
try:
|
||||
if int(entry_status) not in status_codes:
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
if methods:
|
||||
entry_method = log_data.get("method", "").upper()
|
||||
if entry_method not in [m.upper() for m in methods]:
|
||||
return False
|
||||
|
||||
if endpoints:
|
||||
entry_path = log_data.get("path", "")
|
||||
matched = False
|
||||
for endpoint in endpoints:
|
||||
clean_endpoint = endpoint.lstrip("/")
|
||||
if entry_path.startswith(clean_endpoint):
|
||||
matched = True
|
||||
break
|
||||
if clean_endpoint in entry_path:
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
return False
|
||||
|
||||
if search_text_lower:
|
||||
message = str(log_data.get("message", "")).lower()
|
||||
name = str(log_data.get("name", "")).lower()
|
||||
|
||||
+21
-8
@@ -192,21 +192,24 @@ class SecurityFilter(logging.Filter):
|
||||
"""Filter out sensitive information from log records."""
|
||||
try:
|
||||
message = record.getMessage()
|
||||
standalone_patterns = [
|
||||
r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives)
|
||||
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
|
||||
r"nsec[a-z0-9]+", # Nostr Public / Private Key
|
||||
]
|
||||
for pattern in standalone_patterns:
|
||||
message = re.sub(pattern, "[REDACTED]", message, flags=re.IGNORECASE)
|
||||
|
||||
for key in self.SENSITIVE_KEYS:
|
||||
if key in message.lower():
|
||||
patterns = [
|
||||
rf"{key}[:\s=]+([a-zA-Z0-9_\-\.]+)", # key: value or key=value
|
||||
rf'{key}[:\s=]+["\']([^"\']+)["\']', # key: "value" or key='value'
|
||||
r"Bearer\s+([a-zA-Z0-9_\-\.]+)", # Bearer token
|
||||
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
|
||||
key_patterns = [
|
||||
rf"{key}\s*[:=]\s*([a-zA-Z0-9_\-\.=/+]+)", # key:value or key=value (including any variant with spaces)
|
||||
rf'{key}\s*[:=]\s*["\']([^"\']+)["\']', # key:"value" or key='value' (including any variant with spaces)
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
for pattern in key_patterns:
|
||||
message = re.sub(
|
||||
pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE
|
||||
)
|
||||
|
||||
record.msg = message
|
||||
record.args = ()
|
||||
|
||||
@@ -335,6 +338,11 @@ def setup_logging() -> None:
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
"propagate": False,
|
||||
},
|
||||
"openai": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
"propagate": False,
|
||||
},
|
||||
"httpcore": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
@@ -357,6 +365,11 @@ def setup_logging() -> None:
|
||||
},
|
||||
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},
|
||||
"aiosqlite": {"level": "ERROR", "handlers": [], "propagate": False},
|
||||
"alembic": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"level": log_level,
|
||||
|
||||
+27
-20
@@ -13,11 +13,7 @@ from starlette.exceptions import HTTPException
|
||||
from ..balance import balance_router, deprecated_wallet_router
|
||||
from ..discovery import providers_cache_refresher, providers_router
|
||||
from ..nip91 import announce_provider
|
||||
from ..payment.models import (
|
||||
cleanup_enabled_models_periodically,
|
||||
models_router,
|
||||
update_sats_pricing,
|
||||
)
|
||||
from ..payment.models import models_router, update_sats_pricing
|
||||
from ..payment.price import update_prices_periodically
|
||||
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
|
||||
from ..wallet import periodic_payout
|
||||
@@ -34,9 +30,9 @@ setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
if os.getenv("VERSION_SUFFIX") is not None:
|
||||
__version__ = f"0.2.1-{os.getenv('VERSION_SUFFIX')}"
|
||||
__version__ = f"0.3.0-{os.getenv('VERSION_SUFFIX')}"
|
||||
else:
|
||||
__version__ = "0.2.1"
|
||||
__version__ = "0.3.0"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -49,14 +45,10 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
nip91_task = None
|
||||
providers_task = None
|
||||
models_refresh_task = None
|
||||
models_cleanup_task = None
|
||||
model_maps_refresh_task = None
|
||||
|
||||
try:
|
||||
# Run database migrations on startup
|
||||
# This ensures the database schema is always up-to-date in production
|
||||
# Migrations are idempotent - running them multiple times is safe
|
||||
logger.info("Running database migrations")
|
||||
run_migrations()
|
||||
|
||||
# Initialize database connection pools
|
||||
@@ -66,6 +58,15 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# Initialize application settings (env -> computed -> DB precedence)
|
||||
async with create_session() as session:
|
||||
s = await SettingsService.initialize(session)
|
||||
if s.reset_reserved_balance_on_startup:
|
||||
from .db import reset_all_reserved_balances
|
||||
|
||||
await reset_all_reserved_balances(session)
|
||||
|
||||
if not s.admin_password:
|
||||
logger.warning(
|
||||
f"Admin password is not set. Visit {s.http_url or 'http://localhost:8000'}/admin to set the password."
|
||||
)
|
||||
|
||||
# Apply app metadata from settings
|
||||
try:
|
||||
@@ -80,8 +81,13 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
from ..proxy import get_upstreams
|
||||
from ..upstream.helpers import refresh_upstreams_models_periodically
|
||||
|
||||
await _update_prices()
|
||||
await initialize_upstreams()
|
||||
_update_prices_task = asyncio.create_task(_update_prices())
|
||||
_initialize_upstreams_task = asyncio.create_task(initialize_upstreams())
|
||||
|
||||
# ensure both setup tasks complete
|
||||
await asyncio.gather(
|
||||
_update_prices_task, _initialize_upstreams_task, return_exceptions=True
|
||||
)
|
||||
|
||||
btc_price_task = asyncio.create_task(update_prices_periodically())
|
||||
pricing_task = asyncio.create_task(update_sats_pricing())
|
||||
@@ -89,14 +95,18 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
models_refresh_task = asyncio.create_task(
|
||||
refresh_upstreams_models_periodically(get_upstreams())
|
||||
)
|
||||
models_cleanup_task = asyncio.create_task(cleanup_enabled_models_periodically())
|
||||
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
|
||||
payout_task = asyncio.create_task(periodic_payout())
|
||||
nip91_task = asyncio.create_task(announce_provider())
|
||||
providers_task = asyncio.create_task(providers_cache_refresher())
|
||||
if global_settings.nsec:
|
||||
nip91_task = asyncio.create_task(announce_provider())
|
||||
if global_settings.providers_refresh_interval_seconds > 0:
|
||||
providers_task = asyncio.create_task(providers_cache_refresher())
|
||||
|
||||
yield
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Expected during shutdown
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Application startup failed",
|
||||
@@ -118,8 +128,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
providers_task.cancel()
|
||||
if models_refresh_task is not None:
|
||||
models_refresh_task.cancel()
|
||||
if models_cleanup_task is not None:
|
||||
models_cleanup_task.cancel()
|
||||
if model_maps_refresh_task is not None:
|
||||
model_maps_refresh_task.cancel()
|
||||
|
||||
@@ -137,8 +145,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
tasks_to_wait.append(providers_task)
|
||||
if models_refresh_task is not None:
|
||||
tasks_to_wait.append(models_refresh_task)
|
||||
if models_cleanup_task is not None:
|
||||
tasks_to_wait.append(models_cleanup_task)
|
||||
if model_maps_refresh_task is not None:
|
||||
tasks_to_wait.append(model_maps_refresh_task)
|
||||
|
||||
@@ -182,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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -55,7 +55,16 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
"headers": {
|
||||
k: v
|
||||
for k, v in request.headers.items()
|
||||
if k.lower() not in ["authorization", "x-cashu", "cookie"]
|
||||
if k.lower()
|
||||
not in [
|
||||
"authorization",
|
||||
"x-cashu",
|
||||
"cookie",
|
||||
"cf-connecting-ip",
|
||||
"cf-ipcountry",
|
||||
"x-forwarded-for",
|
||||
"x-real-ip",
|
||||
]
|
||||
},
|
||||
"body_size": len(request_body) if request_body else 0,
|
||||
},
|
||||
|
||||
@@ -52,14 +52,18 @@ class Settings(BaseSettings):
|
||||
exchange_fee: float = Field(default=1.005, env="EXCHANGE_FEE")
|
||||
upstream_provider_fee: float = Field(default=1.05, env="UPSTREAM_PROVIDER_FEE")
|
||||
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
|
||||
child_key_cost: int = Field(default=1000, env="CHILD_KEY_COST")
|
||||
# Minimum per-request charge in millisatoshis when model pricing is free/zero
|
||||
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
|
||||
reset_reserved_balance_on_startup: bool = Field(
|
||||
default=True, env="RESET_RESERVED_BALANCE_ON_STARTUP"
|
||||
) # deactivate in horizontal scaling setups
|
||||
|
||||
# Network
|
||||
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
|
||||
tor_proxy_url: str = Field(default="socks5://127.0.0.1:9050", env="TOR_PROXY_URL")
|
||||
providers_refresh_interval_seconds: int = Field(
|
||||
default=300, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
|
||||
default=0, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
|
||||
)
|
||||
pricing_refresh_interval_seconds: int = Field(
|
||||
default=120, env="PRICING_REFRESH_INTERVAL_SECONDS"
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
@@ -62,7 +62,9 @@ async def query_nostr_relay_for_providers(
|
||||
|
||||
if data[0] == "EVENT" and data[1] == sub_id:
|
||||
event = data[2]
|
||||
logger.debug(f"Found provider announcement: {event['id']}")
|
||||
logger.debug(
|
||||
f"Found provider announcement: {event['id'][:6]}...{event['id'][-6:]}"
|
||||
)
|
||||
events.append(event)
|
||||
elif data[0] == "EOSE" and data[1] == sub_id:
|
||||
logger.debug("Received EOSE message")
|
||||
@@ -387,6 +389,9 @@ async def get_providers(
|
||||
Return cached providers. If include_json, return provider+health; otherwise provider only.
|
||||
Optional filter by pubkey.
|
||||
"""
|
||||
if settings.providers_refresh_interval_seconds == 0:
|
||||
raise HTTPException(status_code=404, detail="Provider discovery is disabled")
|
||||
|
||||
cache = await get_cache()
|
||||
if not cache:
|
||||
await refresh_providers_cache(pubkey=pubkey)
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .core.db import ApiKey, LightningInvoice, get_session
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
from .wallet import get_wallet
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
lightning_router = APIRouter(prefix="/lightning")
|
||||
|
||||
|
||||
class InvoiceCreateRequest(BaseModel):
|
||||
amount_sats: int = Field(gt=0, le=1_000_000, description="Amount in satoshis")
|
||||
purpose: str = Field(description="create or topup", pattern="^(create|topup)$")
|
||||
api_key: str | None = Field(
|
||||
default=None, description="Required for topup operations"
|
||||
)
|
||||
|
||||
|
||||
class InvoiceCreateResponse(BaseModel):
|
||||
invoice_id: str
|
||||
bolt11: str
|
||||
amount_sats: int
|
||||
expires_at: int
|
||||
payment_hash: str
|
||||
|
||||
|
||||
class InvoiceStatusResponse(BaseModel):
|
||||
status: str
|
||||
api_key: str | None = None
|
||||
amount_sats: int
|
||||
paid_at: int | None = None
|
||||
created_at: int
|
||||
expires_at: int
|
||||
|
||||
|
||||
class InvoiceRecoverRequest(BaseModel):
|
||||
bolt11: str = Field(description="BOLT11 invoice string")
|
||||
|
||||
|
||||
async def generate_lightning_invoice(
|
||||
amount_sats: int, description: str
|
||||
) -> tuple[str, str]:
|
||||
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||
quote = await wallet.request_mint(amount_sats)
|
||||
return quote.request, quote.quote
|
||||
|
||||
|
||||
def generate_invoice_id() -> str:
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
@lightning_router.post("/invoice", response_model=InvoiceCreateResponse)
|
||||
async def create_invoice(
|
||||
request: InvoiceCreateRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InvoiceCreateResponse:
|
||||
if request.purpose == "topup" and not request.api_key:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="api_key is required for topup operations"
|
||||
)
|
||||
|
||||
if request.purpose == "topup" and request.api_key:
|
||||
if not request.api_key.startswith("sk-"):
|
||||
raise HTTPException(status_code=400, detail="Invalid API key format")
|
||||
|
||||
api_key = await session.get(ApiKey, request.api_key[3:])
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
|
||||
try:
|
||||
description = f"Routstr {request.purpose} {request.amount_sats} sats"
|
||||
bolt11, payment_hash = await generate_lightning_invoice(
|
||||
request.amount_sats, description
|
||||
)
|
||||
|
||||
invoice_id = generate_invoice_id()
|
||||
expires_at = int(time.time()) + 3600 # 1 hour expiry
|
||||
|
||||
invoice = LightningInvoice(
|
||||
id=invoice_id,
|
||||
bolt11=bolt11,
|
||||
amount_sats=request.amount_sats,
|
||||
description=description,
|
||||
payment_hash=payment_hash,
|
||||
status="pending",
|
||||
api_key_hash=request.api_key[3:] if request.api_key else None,
|
||||
purpose=request.purpose,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
session.add(invoice)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"Lightning invoice created",
|
||||
extra={
|
||||
"invoice_id": invoice_id,
|
||||
"amount_sats": request.amount_sats,
|
||||
"purpose": request.purpose,
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
)
|
||||
|
||||
return InvoiceCreateResponse(
|
||||
invoice_id=invoice_id,
|
||||
bolt11=bolt11,
|
||||
amount_sats=request.amount_sats,
|
||||
expires_at=expires_at,
|
||||
payment_hash=payment_hash,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create Lightning invoice: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to create Lightning invoice"
|
||||
)
|
||||
|
||||
|
||||
@lightning_router.get(
|
||||
"/invoice/{invoice_id}/status", response_model=InvoiceStatusResponse
|
||||
)
|
||||
async def get_invoice_status(
|
||||
invoice_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InvoiceStatusResponse:
|
||||
invoice = await session.get(LightningInvoice, invoice_id)
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
if invoice.status == "pending" and int(time.time()) > invoice.expires_at:
|
||||
invoice.status = "expired"
|
||||
await session.commit()
|
||||
|
||||
if invoice.status == "pending":
|
||||
await check_invoice_payment(invoice, session)
|
||||
|
||||
api_key = None
|
||||
if invoice.status == "paid" and invoice.purpose == "create":
|
||||
if invoice.api_key_hash:
|
||||
api_key = f"sk-{invoice.api_key_hash}"
|
||||
elif (
|
||||
invoice.status == "paid" and invoice.purpose == "topup" and invoice.api_key_hash
|
||||
):
|
||||
api_key = f"sk-{invoice.api_key_hash}"
|
||||
|
||||
return InvoiceStatusResponse(
|
||||
status=invoice.status,
|
||||
api_key=api_key,
|
||||
amount_sats=invoice.amount_sats,
|
||||
paid_at=invoice.paid_at,
|
||||
created_at=invoice.created_at,
|
||||
expires_at=invoice.expires_at,
|
||||
)
|
||||
|
||||
|
||||
@lightning_router.post("/recover", response_model=InvoiceStatusResponse)
|
||||
async def recover_invoice(
|
||||
request: InvoiceRecoverRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InvoiceStatusResponse:
|
||||
result = await session.exec(
|
||||
select(LightningInvoice).where(LightningInvoice.bolt11 == request.bolt11)
|
||||
)
|
||||
invoice = result.first()
|
||||
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
if invoice.status == "pending":
|
||||
await check_invoice_payment(invoice, session)
|
||||
|
||||
api_key = None
|
||||
if invoice.status == "paid":
|
||||
if invoice.purpose == "create" and invoice.api_key_hash:
|
||||
api_key = f"sk-{invoice.api_key_hash}"
|
||||
elif invoice.purpose == "topup" and invoice.api_key_hash:
|
||||
api_key = f"sk-{invoice.api_key_hash}"
|
||||
|
||||
return InvoiceStatusResponse(
|
||||
status=invoice.status,
|
||||
api_key=api_key,
|
||||
amount_sats=invoice.amount_sats,
|
||||
paid_at=invoice.paid_at,
|
||||
created_at=invoice.created_at,
|
||||
expires_at=invoice.expires_at,
|
||||
)
|
||||
|
||||
|
||||
async def check_invoice_payment(
|
||||
invoice: LightningInvoice, session: AsyncSession
|
||||
) -> None:
|
||||
try:
|
||||
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||
|
||||
mint_status = await wallet.get_mint_quote(invoice.payment_hash)
|
||||
|
||||
if mint_status.paid:
|
||||
invoice.status = "paid"
|
||||
invoice.paid_at = int(time.time())
|
||||
|
||||
if invoice.purpose == "create":
|
||||
api_key = await create_api_key_from_invoice(invoice, session)
|
||||
invoice.api_key_hash = api_key.hashed_key
|
||||
elif invoice.purpose == "topup" and invoice.api_key_hash:
|
||||
await topup_api_key_from_invoice(invoice, session)
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"Lightning invoice paid",
|
||||
extra={
|
||||
"invoice_id": invoice.id,
|
||||
"amount_sats": invoice.amount_sats,
|
||||
"purpose": invoice.purpose,
|
||||
"api_key_hash": invoice.api_key_hash[:8] + "..."
|
||||
if invoice.api_key_hash
|
||||
else None,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check invoice payment: {e}")
|
||||
|
||||
|
||||
async def create_api_key_from_invoice(
|
||||
invoice: LightningInvoice, session: AsyncSession
|
||||
) -> ApiKey:
|
||||
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||
await wallet.mint(invoice.amount_sats, quote_id=invoice.payment_hash)
|
||||
|
||||
dummy_token = f"invoice-{invoice.id}-{invoice.payment_hash}"
|
||||
hashed_key = hashlib.sha256(dummy_token.encode()).hexdigest()
|
||||
|
||||
api_key = ApiKey(
|
||||
hashed_key=hashed_key,
|
||||
balance=invoice.amount_sats * 1000, # Convert to msats
|
||||
refund_currency="sat",
|
||||
refund_mint_url=settings.primary_mint,
|
||||
)
|
||||
|
||||
session.add(api_key)
|
||||
await session.flush()
|
||||
|
||||
return api_key
|
||||
|
||||
|
||||
async def topup_api_key_from_invoice(
|
||||
invoice: LightningInvoice, session: AsyncSession
|
||||
) -> None:
|
||||
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||
await wallet.mint(invoice.amount_sats, quote_id=invoice.payment_hash)
|
||||
|
||||
if not invoice.api_key_hash:
|
||||
raise ValueError("No API key associated with topup invoice")
|
||||
|
||||
api_key = await session.get(ApiKey, invoice.api_key_hash)
|
||||
if not api_key:
|
||||
raise ValueError("Associated API key not found")
|
||||
|
||||
api_key.balance += invoice.amount_sats * 1000 # Convert to msats
|
||||
await session.flush()
|
||||
+1
-1
@@ -215,7 +215,7 @@ async def query_nip91_events(
|
||||
continue
|
||||
events_out.append(ev_dict)
|
||||
logger.debug(
|
||||
f"Found existing NIP-91 event: {ev_dict.get('id', '')}"
|
||||
f"Found listing event: {ev_dict.get('id', '')[:6]}...{ev_dict.get('id', '')[-6:]}"
|
||||
)
|
||||
if drained:
|
||||
last_event_ts = time.time()
|
||||
|
||||
@@ -5,6 +5,7 @@ from pydantic.v1 import BaseModel
|
||||
from ..core import get_logger
|
||||
from ..core.db import AsyncSession
|
||||
from ..core.settings import settings
|
||||
from .price import sats_usd_price
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -14,6 +15,7 @@ class CostData(BaseModel):
|
||||
input_msats: int
|
||||
output_msats: int
|
||||
total_msats: int
|
||||
total_usd: float = 0.0
|
||||
|
||||
|
||||
class MaxCostData(CostData):
|
||||
@@ -47,13 +49,6 @@ async def calculate_cost( # todo: can be sync
|
||||
},
|
||||
)
|
||||
|
||||
cost_data = MaxCostData(
|
||||
base_msats=max_cost,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=max_cost,
|
||||
)
|
||||
|
||||
if "usage" not in response_data or response_data["usage"] is None:
|
||||
logger.warning(
|
||||
"No usage data in response, using base cost only",
|
||||
@@ -62,7 +57,64 @@ async def calculate_cost( # todo: can be sync
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return cost_data
|
||||
return MaxCostData(
|
||||
base_msats=0,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=0,
|
||||
total_usd=0.0,
|
||||
)
|
||||
|
||||
usage_data = response_data["usage"]
|
||||
|
||||
usd_cost = 0.0
|
||||
|
||||
# Prioritize cost_details.upstream_inference_cost
|
||||
if "cost_details" in usage_data:
|
||||
usd_cost = float(
|
||||
usage_data["cost_details"].get("upstream_inference_cost", 0) or 0
|
||||
)
|
||||
|
||||
# Fallback to cost field if upstream_inference_cost is 0
|
||||
if usd_cost == 0 and "cost" in usage_data:
|
||||
try:
|
||||
usd_cost = float(usage_data.get("cost", 0) or 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if usd_cost > 0:
|
||||
try:
|
||||
sats_per_usd = 1.0 / sats_usd_price()
|
||||
cost_in_sats = usd_cost * sats_per_usd
|
||||
cost_in_msats = math.ceil(cost_in_sats * 1000)
|
||||
|
||||
logger.info(
|
||||
"Using cost from usage data/details",
|
||||
extra={
|
||||
"usd_cost": usd_cost,
|
||||
"cost_in_sats": cost_in_sats,
|
||||
"cost_in_msats": cost_in_msats,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
return CostData(
|
||||
base_msats=-1,
|
||||
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(
|
||||
"Error calculating cost from usage data",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"usd_cost": usd_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
# Fall through to token-based calculation
|
||||
|
||||
MSATS_PER_1K_INPUT_TOKENS: float = (
|
||||
float(settings.fixed_per_1k_input_tokens) * 1000.0
|
||||
@@ -127,14 +179,41 @@ async def calculate_cost( # todo: can be sync
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return cost_data
|
||||
return MaxCostData(
|
||||
base_msats=max_cost,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=max_cost,
|
||||
)
|
||||
|
||||
input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0)
|
||||
output_tokens = response_data.get("usage", {}).get("completion_tokens", 0)
|
||||
input_tokens = usage_data.get("prompt_tokens", 0)
|
||||
output_tokens = usage_data.get("completion_tokens", 0)
|
||||
|
||||
# added for response api
|
||||
input_tokens = (
|
||||
input_tokens if input_tokens != 0 else usage_data.get("input_tokens", 0)
|
||||
)
|
||||
output_tokens = (
|
||||
output_tokens if output_tokens != 0 else usage_data.get("output_tokens", 0)
|
||||
)
|
||||
|
||||
# added for response api
|
||||
input_tokens = (
|
||||
input_tokens
|
||||
if input_tokens != 0
|
||||
else response_data.get("usage", {}).get("input_tokens", 0)
|
||||
)
|
||||
output_tokens = (
|
||||
output_tokens
|
||||
if output_tokens != 0
|
||||
else response_data.get("usage", {}).get("output_tokens", 0)
|
||||
)
|
||||
|
||||
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
|
||||
|
||||
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",
|
||||
@@ -144,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"),
|
||||
},
|
||||
)
|
||||
@@ -153,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,
|
||||
)
|
||||
|
||||
@@ -283,7 +283,7 @@ async def raw_send_to_lnurl(
|
||||
f"({min_sendable_sat} - {max_sendable_sat} {unit})"
|
||||
)
|
||||
|
||||
estimated_fees_sat = int(max(math.ceil((amount_msat / 1000) * 0.01), 2))
|
||||
estimated_fees_sat = int(max(math.ceil((amount_msat / 1000) * 0.01), 2)) + 1
|
||||
estimated_fees_msat = estimated_fees_sat * 1000
|
||||
final_amount = amount_msat - estimated_fees_msat
|
||||
|
||||
@@ -291,9 +291,7 @@ async def raw_send_to_lnurl(
|
||||
lnurl_data["callback_url"], final_amount
|
||||
)
|
||||
|
||||
melt_quote_resp = await wallet.melt_quote(
|
||||
invoice=bolt11_invoice, amount_msat=final_amount
|
||||
)
|
||||
melt_quote_resp = await wallet.melt_quote(invoice=bolt11_invoice)
|
||||
|
||||
if amount:
|
||||
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
|
||||
|
||||
+71
-336
@@ -1,17 +1,13 @@
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
from urllib.request import urlopen
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic.v1 import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..core.db import ModelRow, create_session, get_session
|
||||
from ..core.db import ModelRow, get_session
|
||||
from ..core.logging import get_logger
|
||||
from ..core.settings import settings
|
||||
from .price import sats_usd_price
|
||||
@@ -20,14 +16,6 @@ logger = get_logger(__name__)
|
||||
|
||||
models_router = APIRouter()
|
||||
|
||||
DEFAULT_EXCLUDED_MODEL_IDS: Final[set[str]] = {
|
||||
"openrouter/auto",
|
||||
"google/gemini-2.5-pro-exp-03-25",
|
||||
"opengvlab/internvl3-78b",
|
||||
"openrouter/sonoma-dusk-alpha",
|
||||
"openrouter/sonoma-sky-alpha",
|
||||
}
|
||||
|
||||
|
||||
class Architecture(BaseModel):
|
||||
modality: str
|
||||
@@ -40,10 +28,12 @@ class Architecture(BaseModel):
|
||||
class Pricing(BaseModel):
|
||||
prompt: float
|
||||
completion: float
|
||||
request: float
|
||||
image: float
|
||||
web_search: float
|
||||
internal_reasoning: float
|
||||
request: float = 0.0
|
||||
image: float = 0.0
|
||||
web_search: float = 0.0
|
||||
internal_reasoning: float = 0.0
|
||||
input_cache_read: float = 0.0
|
||||
input_cache_write: float = 0.0
|
||||
max_prompt_cost: float = 0.0 # in sats not msats
|
||||
max_completion_cost: float = 0.0 # in sats not msats
|
||||
max_cost: float = 0.0 # in sats not msats
|
||||
@@ -67,7 +57,7 @@ class Model(BaseModel):
|
||||
per_request_limits: dict | None = None
|
||||
top_provider: TopProvider | None = None
|
||||
enabled: bool = True
|
||||
upstream_provider_id: int | None = None
|
||||
upstream_provider_id: int | str | None = None
|
||||
canonical_slug: str | None = None
|
||||
alias_ids: list[str] | None = None
|
||||
|
||||
@@ -75,39 +65,25 @@ class Model(BaseModel):
|
||||
return hash(self.id)
|
||||
|
||||
|
||||
def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
|
||||
"""Fetches model information from OpenRouter API."""
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
def _has_valid_pricing(model: dict) -> bool:
|
||||
"""Check if model has valid pricing (not free, no negative values)."""
|
||||
pricing = model.get("pricing", {})
|
||||
if not pricing:
|
||||
return False
|
||||
|
||||
try:
|
||||
with urlopen(f"{base_url}/models") as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
prompt = float(pricing.get("prompt", 0))
|
||||
completion = float(pricing.get("completion", 0))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
models_data: list[dict] = []
|
||||
for model in data.get("data", []):
|
||||
model_id = model.get("id", "")
|
||||
if prompt < 0 or completion < 0:
|
||||
return False
|
||||
|
||||
if source_filter:
|
||||
source_prefix = f"{source_filter}/"
|
||||
if not model_id.startswith(source_prefix):
|
||||
continue
|
||||
if prompt == 0 and completion == 0:
|
||||
return False
|
||||
|
||||
model = dict(model)
|
||||
model["id"] = model_id[len(source_prefix) :]
|
||||
model_id = model["id"]
|
||||
|
||||
if (
|
||||
"(free)" in model.get("name", "")
|
||||
or model_id in DEFAULT_EXCLUDED_MODEL_IDS
|
||||
):
|
||||
continue
|
||||
|
||||
models_data.append(model)
|
||||
|
||||
return models_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching models from OpenRouter API: {e}")
|
||||
return []
|
||||
return True
|
||||
|
||||
|
||||
async def async_fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
|
||||
@@ -116,12 +92,32 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(f"{base_url}/models", timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
models_response, embeddings_response = await asyncio.gather(
|
||||
client.get(f"{base_url}/models", timeout=30),
|
||||
client.get(f"{base_url}/embeddings/models", timeout=30),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
def process_models_response(
|
||||
response: httpx.Response | BaseException,
|
||||
) -> list[dict]:
|
||||
if not isinstance(response, BaseException):
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return [
|
||||
model
|
||||
for model in data.get("data", [])
|
||||
if ":free" not in model.get("id", "").lower()
|
||||
]
|
||||
return []
|
||||
|
||||
models_data: list[dict] = []
|
||||
for model in data.get("data", []):
|
||||
models_data.extend(process_models_response(models_response))
|
||||
models_data.extend(process_models_response(embeddings_response))
|
||||
|
||||
# Apply source filter and exclusions
|
||||
filtered_models = []
|
||||
for model in models_data:
|
||||
model_id = model.get("id", "")
|
||||
|
||||
if source_filter:
|
||||
@@ -133,15 +129,15 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
|
||||
model["id"] = model_id[len(source_prefix) :]
|
||||
model_id = model["id"]
|
||||
|
||||
if (
|
||||
"(free)" in model.get("name", "")
|
||||
or model_id in DEFAULT_EXCLUDED_MODEL_IDS
|
||||
):
|
||||
if "(free)" in model.get("name", ""):
|
||||
continue
|
||||
|
||||
models_data.append(model)
|
||||
if not _has_valid_pricing(model):
|
||||
continue
|
||||
|
||||
return models_data
|
||||
filtered_models.append(model)
|
||||
|
||||
return filtered_models
|
||||
except Exception as e:
|
||||
logger.error(f"Error (async) fetching models from OpenRouter API: {e}")
|
||||
return []
|
||||
@@ -155,54 +151,6 @@ def is_openrouter_upstream() -> bool:
|
||||
return base.lower() == "https://openrouter.ai/api/v1"
|
||||
|
||||
|
||||
def load_models() -> list[Model]:
|
||||
"""Load model definitions from a JSON file or auto-generate from OpenRouter API.
|
||||
|
||||
The file path can be specified via the ``MODELS_PATH`` environment variable.
|
||||
If a user-provided models.json exists, it will be used. Otherwise, models are
|
||||
automatically fetched from OpenRouter API in memory. If the example file exists
|
||||
and no user file is provided, it will be used as a fallback.
|
||||
"""
|
||||
|
||||
try:
|
||||
models_path = Path(settings.models_path)
|
||||
except Exception:
|
||||
models_path = Path("models.json")
|
||||
|
||||
# Check if user has actively provided a models.json file
|
||||
if models_path.exists():
|
||||
logger.info(f"Loading models from user-provided file: {models_path}")
|
||||
try:
|
||||
with models_path.open("r") as f:
|
||||
data = json.load(f)
|
||||
return [Model(**model) for model in data.get("models", [])] # type: ignore
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading models from {models_path}: {e}")
|
||||
# Fall through to auto-generation
|
||||
|
||||
# Only auto-generate from OpenRouter when upstream is OpenRouter
|
||||
if not is_openrouter_upstream():
|
||||
logger.info(
|
||||
"Skipping auto-generation from OpenRouter because upstream_base_url is not https://openrouter.ai/api/v1"
|
||||
)
|
||||
return []
|
||||
|
||||
logger.info("Auto-generating models from OpenRouter API")
|
||||
try:
|
||||
source_filter = settings.source or None
|
||||
except Exception:
|
||||
source_filter = None
|
||||
source_filter = source_filter if source_filter and source_filter.strip() else None
|
||||
|
||||
models_data = fetch_openrouter_models(source_filter=source_filter)
|
||||
if not models_data:
|
||||
logger.error("Failed to fetch models from OpenRouter API")
|
||||
return []
|
||||
|
||||
logger.info(f"Successfully fetched {len(models_data)} models from OpenRouter API")
|
||||
return [Model(**model) for model in models_data] # type: ignore
|
||||
|
||||
|
||||
def _row_to_model(
|
||||
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
|
||||
) -> Model:
|
||||
@@ -236,6 +184,7 @@ def _row_to_model(
|
||||
enabled=row.enabled,
|
||||
upstream_provider_id=row.upstream_provider_id,
|
||||
canonical_slug=getattr(row, "canonical_slug", None),
|
||||
alias_ids=json.loads(row.alias_ids) if row.alias_ids else None,
|
||||
)
|
||||
|
||||
if apply_provider_fee:
|
||||
@@ -281,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
|
||||
|
||||
@@ -298,12 +248,17 @@ 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,
|
||||
)
|
||||
for r in rows
|
||||
if include_disabled
|
||||
or (
|
||||
r.upstream_provider_id in providers_by_id
|
||||
and providers_by_id[r.upstream_provider_id].enabled
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -316,6 +271,8 @@ async def get_model_by_id(
|
||||
if not row or not row.enabled:
|
||||
return None
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider or not provider.enabled:
|
||||
return None
|
||||
provider_fee = provider.provider_fee if provider else 1.01
|
||||
return _row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
|
||||
|
||||
@@ -431,60 +388,9 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
|
||||
return model
|
||||
|
||||
|
||||
async def ensure_models_bootstrapped() -> None:
|
||||
async with create_session() as s:
|
||||
existing = (await s.exec(select(ModelRow.id).limit(1))).all() # type: ignore
|
||||
if existing:
|
||||
return
|
||||
|
||||
try:
|
||||
models_path = Path(settings.models_path)
|
||||
except Exception:
|
||||
models_path = Path("models.json")
|
||||
|
||||
models_to_insert: list[dict] = []
|
||||
if models_path.exists():
|
||||
try:
|
||||
with models_path.open("r") as f:
|
||||
data = json.load(f)
|
||||
models_to_insert = data.get("models", [])
|
||||
logger.info(
|
||||
f"Bootstrapping {len(models_to_insert)} models from {models_path}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading models from {models_path}: {e}")
|
||||
|
||||
if not models_to_insert and is_openrouter_upstream():
|
||||
logger.info("Bootstrapping models from OpenRouter API")
|
||||
source_filter = None
|
||||
try:
|
||||
src = settings.source or None
|
||||
source_filter = src if src and src.strip() else None
|
||||
except Exception:
|
||||
pass
|
||||
models_to_insert = fetch_openrouter_models(source_filter=source_filter)
|
||||
elif not models_to_insert:
|
||||
logger.info(
|
||||
"No models.json found and upstream is not OpenRouter; skipping bootstrap"
|
||||
)
|
||||
|
||||
for m in models_to_insert:
|
||||
try:
|
||||
model = Model(**m) # type: ignore
|
||||
except Exception:
|
||||
# Some OpenRouter models include extra fields; only map required ones
|
||||
continue
|
||||
exists = await s.get(ModelRow, model.id)
|
||||
if exists:
|
||||
continue
|
||||
payload = _model_to_row_payload(model)
|
||||
s.add(ModelRow(**payload)) # type: ignore
|
||||
await s.commit()
|
||||
|
||||
|
||||
async def _update_sats_pricing_once() -> None:
|
||||
"""Update sats pricing once for all provider models (in-memory only)."""
|
||||
from ..proxy import get_upstreams
|
||||
from ..proxy import get_upstreams, refresh_model_maps
|
||||
|
||||
upstreams = get_upstreams()
|
||||
sats_to_usd = sats_usd_price()
|
||||
@@ -501,6 +407,7 @@ async def _update_sats_pricing_once() -> None:
|
||||
|
||||
if updated_count > 0:
|
||||
logger.info("Updated sats pricing", extra={"models_updated": updated_count})
|
||||
await refresh_model_maps()
|
||||
|
||||
|
||||
async def update_sats_pricing() -> None:
|
||||
@@ -511,7 +418,13 @@ async def update_sats_pricing() -> None:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await _update_sats_pricing_once()
|
||||
try:
|
||||
await _update_sats_pricing_once()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Initial sats pricing update failed (will retry in loop)",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -535,184 +448,6 @@ async def update_sats_pricing() -> None:
|
||||
logger.error(f"Error updating sats pricing: {e}")
|
||||
|
||||
|
||||
async def cleanup_enabled_models_periodically() -> None:
|
||||
"""Background task to clean up enabled models that match upstream pricing.
|
||||
|
||||
When model is enabled (enabled=True), remove it from DB if it matches upstream pricing.
|
||||
Keep it in DB only if pricing differs from upstream or if it's disabled.
|
||||
"""
|
||||
interval = getattr(
|
||||
settings, "models_cleanup_interval_seconds", 300
|
||||
) # 5 minutes default
|
||||
if not interval or interval <= 0:
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
await _cleanup_enabled_models_once()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error during enabled models cleanup",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
try:
|
||||
jitter = max(0.0, float(interval) * 0.1)
|
||||
await asyncio.sleep(interval + random.uniform(0, jitter))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
|
||||
async def _cleanup_enabled_models_once() -> None:
|
||||
"""Clean up enabled models that match upstream pricing."""
|
||||
from ..proxy import get_upstreams
|
||||
|
||||
async with create_session() as session:
|
||||
# Get all enabled models from DB
|
||||
result = await session.exec(
|
||||
select(ModelRow).where(
|
||||
ModelRow.enabled, # Only enabled models
|
||||
)
|
||||
)
|
||||
db_models = result.all()
|
||||
|
||||
if not db_models:
|
||||
return
|
||||
|
||||
upstreams = get_upstreams()
|
||||
models_to_remove = []
|
||||
|
||||
for db_model in db_models:
|
||||
# Find corresponding upstream model
|
||||
upstream_model = None
|
||||
for upstream in upstreams:
|
||||
upstream_model = upstream.get_cached_model_by_id(db_model.id)
|
||||
if upstream_model:
|
||||
break
|
||||
|
||||
if not upstream_model:
|
||||
continue
|
||||
|
||||
# Compare pricing to see if they match
|
||||
db_pricing = json.loads(db_model.pricing)
|
||||
upstream_pricing = upstream_model.pricing.dict()
|
||||
|
||||
# Check if pricing matches (with small tolerance for float comparison)
|
||||
pricing_matches = _pricing_matches(db_pricing, upstream_pricing)
|
||||
|
||||
if pricing_matches:
|
||||
models_to_remove.append(db_model)
|
||||
logger.info(
|
||||
f"Removing enabled model {db_model.id} - matches upstream pricing",
|
||||
extra={"model_id": db_model.id},
|
||||
)
|
||||
|
||||
# Remove models that match upstream pricing
|
||||
for model in models_to_remove:
|
||||
await session.delete(model)
|
||||
|
||||
if models_to_remove:
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Cleaned up {len(models_to_remove)} enabled models that match upstream pricing"
|
||||
)
|
||||
|
||||
|
||||
def _pricing_matches(
|
||||
db_pricing: dict, upstream_pricing: dict, tolerance: float = 0.0
|
||||
) -> bool:
|
||||
"""Check if pricing dictionaries match within tolerance."""
|
||||
keys_to_compare = [
|
||||
"prompt",
|
||||
"completion",
|
||||
"request",
|
||||
"image",
|
||||
"web_search",
|
||||
"internal_reasoning",
|
||||
]
|
||||
|
||||
for key in keys_to_compare:
|
||||
db_val = int(float(db_pricing.get(key, 0.0)) * 1000000)
|
||||
upstream_val = int(float(upstream_pricing.get(key, 0.0)) * 1000000)
|
||||
|
||||
if abs(db_val - upstream_val) > tolerance:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def refresh_models_periodically() -> None:
|
||||
"""Background task: periodically fetch OpenRouter models and insert new ones.
|
||||
|
||||
- Respects optional SOURCE filter from settings
|
||||
- Does not overwrite existing rows
|
||||
- Sleeps according to settings.models_refresh_interval_seconds; disabled when 0
|
||||
"""
|
||||
interval = getattr(settings, "models_refresh_interval_seconds", 0)
|
||||
if not interval or interval <= 0:
|
||||
return
|
||||
|
||||
# Only refresh from OpenRouter when upstream is OpenRouter
|
||||
if not is_openrouter_upstream():
|
||||
logger.info("Skipping models refresh: upstream_base_url is not OpenRouter")
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
try:
|
||||
if not settings.enable_models_refresh:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
src = settings.source or None
|
||||
source_filter = src if src and src.strip() else None
|
||||
except Exception:
|
||||
source_filter = None
|
||||
|
||||
models = fetch_openrouter_models(source_filter=source_filter)
|
||||
if not models:
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
|
||||
async with create_session() as s:
|
||||
result = await s.exec(select(ModelRow.id)) # type: ignore
|
||||
existing_ids = {
|
||||
row[0] if isinstance(row, tuple) else row for row in result.all()
|
||||
}
|
||||
inserted = 0
|
||||
for m in models:
|
||||
try:
|
||||
model = Model(**m) # type: ignore
|
||||
except Exception:
|
||||
continue
|
||||
if model.id in existing_ids:
|
||||
continue
|
||||
payload = _model_to_row_payload(model)
|
||||
try:
|
||||
s.add(ModelRow(**payload)) # type: ignore
|
||||
except Exception:
|
||||
pass
|
||||
inserted += 1
|
||||
if inserted:
|
||||
await s.commit()
|
||||
logger.info(f"Inserted {inserted} new models from OpenRouter")
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error during models refresh",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
try:
|
||||
jitter = max(0.0, float(interval) * 0.1)
|
||||
await asyncio.sleep(interval + random.uniform(0, jitter))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
|
||||
@models_router.get("/v1/models")
|
||||
@models_router.get("/models", include_in_schema=False)
|
||||
async def models(session: AsyncSession = Depends(get_session)) -> dict:
|
||||
|
||||
+20
-10
@@ -79,15 +79,29 @@ async def _fetch_btc_usd_price() -> float:
|
||||
"""Fetch the lowest BTC/USD price from multiple exchanges."""
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
prices = await asyncio.gather(
|
||||
_kraken_btc_usd(client),
|
||||
_coinbase_btc_usd(client),
|
||||
_binance_btc_usdt(client),
|
||||
)
|
||||
valid_prices = [price for price in prices if price is not None]
|
||||
tasks = [
|
||||
asyncio.create_task(_kraken_btc_usd(client)),
|
||||
asyncio.create_task(_coinbase_btc_usd(client)),
|
||||
asyncio.create_task(_binance_btc_usdt(client)),
|
||||
]
|
||||
valid_prices: list[float] = []
|
||||
|
||||
for future in asyncio.as_completed(tasks):
|
||||
price = await future
|
||||
if price is not None:
|
||||
valid_prices.append(price)
|
||||
|
||||
if len(valid_prices) >= 2:
|
||||
break
|
||||
|
||||
for task in tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
if not valid_prices:
|
||||
logger.error("No valid BTC prices obtained from any exchange")
|
||||
raise ValueError("Unable to fetch BTC price from any exchange")
|
||||
|
||||
return min(valid_prices)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
@@ -110,10 +124,6 @@ async def _update_prices() -> None:
|
||||
return
|
||||
BTC_USD_PRICE = btc_price
|
||||
SATS_USD_PRICE = btc_price / 100_000_000
|
||||
logger.info(
|
||||
"Updated BTC/USD price",
|
||||
extra={"btc_usd": btc_price, "sats_usd": SATS_USD_PRICE},
|
||||
)
|
||||
|
||||
|
||||
def btc_usd_price() -> float:
|
||||
|
||||
+220
-77
@@ -16,6 +16,7 @@ from .core.db import (
|
||||
create_session,
|
||||
get_session,
|
||||
)
|
||||
from .core.exceptions import UpstreamError
|
||||
from .payment.helpers import (
|
||||
calculate_discounted_max_cost,
|
||||
check_token_balance,
|
||||
@@ -31,7 +32,9 @@ proxy_router = APIRouter()
|
||||
|
||||
_upstreams: list[BaseUpstreamProvider] = []
|
||||
_model_instances: dict[str, Model] = {} # All aliases -> Model
|
||||
_provider_map: dict[str, BaseUpstreamProvider] = {} # All aliases -> Provider
|
||||
_provider_map: dict[
|
||||
str, list[BaseUpstreamProvider]
|
||||
] = {} # All aliases -> List[Provider]
|
||||
_unique_models: dict[str, Model] = {} # Unique model.id -> Model (no duplicates)
|
||||
|
||||
|
||||
@@ -65,12 +68,12 @@ def get_upstreams() -> list[BaseUpstreamProvider]:
|
||||
|
||||
def get_model_instance(model_id: str) -> Model | None:
|
||||
"""Get Model instance by ID from global cache."""
|
||||
return _model_instances.get(model_id)
|
||||
return _model_instances.get(model_id.lower())
|
||||
|
||||
|
||||
def get_provider_for_model(model_id: str) -> BaseUpstreamProvider | None:
|
||||
"""Get UpstreamProvider for model ID from global cache."""
|
||||
return _provider_map.get(model_id)
|
||||
def get_provider_for_model(model_id: str) -> list[BaseUpstreamProvider] | None:
|
||||
"""Get UpstreamProvider list for model ID from global cache."""
|
||||
return _provider_map.get(model_id.lower())
|
||||
|
||||
|
||||
def get_unique_models() -> list[Model]:
|
||||
@@ -80,31 +83,29 @@ def get_unique_models() -> list[Model]:
|
||||
|
||||
async def refresh_model_maps() -> None:
|
||||
"""Refresh global model and provider maps using the cost-based algorithm."""
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
global _model_instances, _provider_map, _unique_models
|
||||
|
||||
# Gather database overrides and disabled models
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(ModelRow).where(ModelRow.enabled))
|
||||
override_rows = result.all()
|
||||
|
||||
provider_result = await session.exec(select(UpstreamProviderRow))
|
||||
providers_by_id = {p.id: p for p in provider_result.all()}
|
||||
|
||||
overrides_by_id: dict[str, tuple[ModelRow, float]] = {
|
||||
row.id: (
|
||||
row,
|
||||
providers_by_id[row.upstream_provider_id].provider_fee
|
||||
if row.upstream_provider_id in providers_by_id
|
||||
else 1.01,
|
||||
)
|
||||
for row in override_rows
|
||||
if row.upstream_provider_id is not None
|
||||
}
|
||||
|
||||
disabled_result = await session.exec(
|
||||
select(ModelRow.id).where(ModelRow.enabled == False) # noqa: E712
|
||||
# Fetch all providers with their models in a single logical operation
|
||||
query = select(UpstreamProviderRow).options(
|
||||
selectinload(UpstreamProviderRow.models) # type: ignore
|
||||
)
|
||||
disabled_model_ids = {row for row in disabled_result.all()}
|
||||
result = await session.exec(query)
|
||||
provider_rows = result.all()
|
||||
|
||||
overrides_by_id: dict[str, tuple[ModelRow, float]] = {}
|
||||
disabled_model_ids: set[str] = set()
|
||||
|
||||
for provider in provider_rows:
|
||||
if not provider.enabled:
|
||||
continue
|
||||
for model in provider.models:
|
||||
if model.enabled:
|
||||
overrides_by_id[model.id] = (model, provider.provider_fee)
|
||||
else:
|
||||
disabled_model_ids.add(model.id)
|
||||
|
||||
_model_instances, _provider_map, _unique_models = create_model_mappings(
|
||||
upstreams=_upstreams,
|
||||
@@ -141,20 +142,14 @@ async def proxy(
|
||||
"unauthorized", "Unauthorized", 401, request=request
|
||||
)
|
||||
|
||||
logger.info( # TODO: move to middleware, async
|
||||
"Received proxy request",
|
||||
extra={
|
||||
"method": request.method,
|
||||
"path": path,
|
||||
"client_host": request.client.host if request.client else "unknown",
|
||||
"user_agent": request.headers.get("user-agent", "unknown")[:100],
|
||||
},
|
||||
)
|
||||
|
||||
is_responses_api = path.startswith("v1/responses") or path.startswith("responses")
|
||||
request_body = await request.body()
|
||||
request_body_dict = parse_request_body_json(request_body, path)
|
||||
|
||||
model_id = request_body_dict.get("model", "unknown")
|
||||
if is_responses_api:
|
||||
model_id = extract_model_from_responses_request(request_body_dict)
|
||||
else:
|
||||
model_id = request_body_dict.get("model", "unknown")
|
||||
|
||||
model_obj = get_model_instance(model_id)
|
||||
if not model_obj:
|
||||
@@ -162,8 +157,8 @@ async def proxy(
|
||||
"invalid_model", f"Model '{model_id}' not found", 400, request=request
|
||||
)
|
||||
|
||||
upstream = get_provider_for_model(model_id)
|
||||
if not upstream:
|
||||
upstreams = get_provider_for_model(model_id)
|
||||
if not upstreams:
|
||||
return create_error_response(
|
||||
"invalid_model",
|
||||
f"No provider found for model '{model_id}'",
|
||||
@@ -171,6 +166,10 @@ async def proxy(
|
||||
request=request,
|
||||
)
|
||||
|
||||
# todo figure out cost calculation since fallback provider is usually not the same price
|
||||
# Use first provider for initial checks/cost calculation
|
||||
# primary_upstream = upstreams[0]
|
||||
|
||||
_max_cost_for_model = await get_max_cost_for_model(
|
||||
model=model_id, session=session, model_obj=model_obj
|
||||
)
|
||||
@@ -180,8 +179,30 @@ async def proxy(
|
||||
check_token_balance(headers, request_body_dict, max_cost_for_model)
|
||||
|
||||
if x_cashu := headers.get("x-cashu", None):
|
||||
return await upstream.handle_x_cashu(
|
||||
request, x_cashu, path, max_cost_for_model, model_obj
|
||||
last_error = None
|
||||
for i, upstream in enumerate(upstreams):
|
||||
try:
|
||||
if is_responses_api:
|
||||
return await upstream.handle_x_cashu_responses(
|
||||
request, x_cashu, path, max_cost_for_model, model_obj
|
||||
)
|
||||
else:
|
||||
return await upstream.handle_x_cashu(
|
||||
request, x_cashu, path, max_cost_for_model, model_obj
|
||||
)
|
||||
except UpstreamError as e:
|
||||
logger.warning(
|
||||
f"Upstream {upstream.provider_type} failed (x-cashu): {e}"
|
||||
)
|
||||
if i == len(upstreams) - 1:
|
||||
last_error = e
|
||||
continue
|
||||
|
||||
return create_error_response(
|
||||
"upstream_error",
|
||||
str(last_error) if last_error else "All upstreams failed",
|
||||
502,
|
||||
request=request,
|
||||
)
|
||||
|
||||
elif auth := headers.get("authorization", None):
|
||||
@@ -197,49 +218,153 @@ async def proxy(
|
||||
)
|
||||
|
||||
logger.debug("Processing unauthenticated GET request", extra={"path": path})
|
||||
# TODO: why is this needed? can we remove it?
|
||||
headers = upstream.prepare_headers(dict(request.headers))
|
||||
return await upstream.forward_get_request(request, path, headers)
|
||||
|
||||
# Only pay for request if we have request body data (for completions endpoints)
|
||||
last_error_response = None
|
||||
for i, upstream in enumerate(upstreams):
|
||||
try:
|
||||
headers = upstream.prepare_headers(dict(request.headers))
|
||||
response = await upstream.forward_get_request(request, path, headers)
|
||||
|
||||
if response.status_code in [502, 429] and i < len(upstreams) - 1:
|
||||
error_message = ""
|
||||
try:
|
||||
if hasattr(response, "body"):
|
||||
body_bytes = response.body
|
||||
data = json.loads(body_bytes)
|
||||
if "error" in data:
|
||||
error_data = data["error"]
|
||||
if isinstance(error_data, dict):
|
||||
error_message = error_data.get("message", "")
|
||||
elif isinstance(error_data, str):
|
||||
error_message = error_data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await upstream.on_upstream_error_redirect(
|
||||
response.status_code, error_message
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
f"Upstream {upstream.provider_type} returned {response.status_code} (GET), trying next provider",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"upstream": upstream.provider_type,
|
||||
},
|
||||
)
|
||||
continue
|
||||
return response
|
||||
except UpstreamError as e:
|
||||
logger.warning(f"Upstream {upstream.provider_type} failed (GET): {e}")
|
||||
if i == len(upstreams) - 1:
|
||||
last_error_response = create_error_response(
|
||||
"upstream_error", str(e), 502, request=request
|
||||
)
|
||||
continue
|
||||
return last_error_response or create_error_response(
|
||||
"upstream_error", "All upstreams failed", 502, request=request
|
||||
)
|
||||
|
||||
if request_body_dict:
|
||||
await pay_for_request(key, max_cost_for_model, session)
|
||||
|
||||
# Prepare headers for upstream
|
||||
headers = upstream.prepare_headers(dict(request.headers))
|
||||
for i, upstream in enumerate(upstreams):
|
||||
headers = upstream.prepare_headers(dict(request.headers))
|
||||
|
||||
# Forward to upstream and handle response
|
||||
response = await upstream.forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
try:
|
||||
if is_responses_api:
|
||||
response = await upstream.forward_responses_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
else:
|
||||
response = await upstream.forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
# Check if we should retry (502 Upstream Error or 429 Rate Limit)
|
||||
should_retry = response.status_code in [502, 429, 400, 401, 403, 404]
|
||||
if should_retry and i < len(upstreams) - 1:
|
||||
error_message = ""
|
||||
try:
|
||||
if hasattr(response, "body"):
|
||||
body_bytes = response.body
|
||||
data = json.loads(body_bytes)
|
||||
if "error" in data:
|
||||
error_data = data["error"]
|
||||
if isinstance(error_data, dict):
|
||||
error_message = error_data.get("message", "")
|
||||
elif isinstance(error_data, str):
|
||||
error_message = error_data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await upstream.on_upstream_error_redirect(
|
||||
response.status_code, error_message
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
f"Upstream {upstream.provider_type} returned {response.status_code}, trying next provider",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"upstream": upstream.provider_type,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
# 4xx error (user error), or other non-retryable error, or last provider failed
|
||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||
logger.warning(
|
||||
"Upstream request failed, revert payment",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"path": path,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"key_balance": key.balance,
|
||||
"max_cost_for_model": max_cost_for_model,
|
||||
"upstream_headers": response.headers
|
||||
if hasattr(response, "headers")
|
||||
else None,
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
||||
return response
|
||||
|
||||
except UpstreamError as e:
|
||||
logger.warning(
|
||||
f"Upstream {upstream.provider_type} failed: {e}",
|
||||
extra={"retry": i < len(upstreams) - 1},
|
||||
)
|
||||
|
||||
# If this was the last provider
|
||||
if i == len(upstreams) - 1:
|
||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||
return create_error_response(
|
||||
"upstream_error", str(e), 502, request=request
|
||||
)
|
||||
|
||||
# Otherwise loop continues to next provider
|
||||
continue
|
||||
|
||||
# Should not be reached given logic above
|
||||
return create_error_response(
|
||||
"upstream_error", "All upstreams failed", 502, request=request
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||
logger.warning(
|
||||
"Upstream request failed, revert payment",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"path": path,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"key_balance": key.balance,
|
||||
"max_cost_for_model": max_cost_for_model,
|
||||
"upstream_headers": response.headers
|
||||
if hasattr(response, "headers")
|
||||
else None,
|
||||
},
|
||||
)
|
||||
# Return the mapped error response generated earlier rather than masking with 502
|
||||
return response
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def get_bearer_token_key(
|
||||
headers: dict, path: str, session: AsyncSession, auth: str
|
||||
@@ -321,6 +446,24 @@ async def get_bearer_token_key(
|
||||
raise
|
||||
|
||||
|
||||
def extract_model_from_responses_request(request_body_dict: dict[str, Any]) -> str:
|
||||
if model := request_body_dict.get("model"):
|
||||
return model
|
||||
|
||||
if input_data := request_body_dict.get("input"):
|
||||
if isinstance(input_data, dict) and (model := input_data.get("model")):
|
||||
return model
|
||||
|
||||
if request_body_dict.get("messages"):
|
||||
return "unknown"
|
||||
|
||||
logger.warning(
|
||||
"No model found in Responses API request",
|
||||
extra={"body_keys": list(request_body_dict.keys())},
|
||||
)
|
||||
return "unknown"
|
||||
|
||||
|
||||
def parse_request_body_json(request_body: bytes, path: str) -> dict[str, Any]:
|
||||
request_body_dict = {}
|
||||
if request_body:
|
||||
|
||||
@@ -2,24 +2,28 @@ from .anthropic import AnthropicUpstreamProvider
|
||||
from .azure import AzureUpstreamProvider
|
||||
from .base import BaseUpstreamProvider
|
||||
from .fireworks import FireworksUpstreamProvider
|
||||
from .gemini import GeminiUpstreamProvider
|
||||
from .generic import GenericUpstreamProvider
|
||||
from .groq import GroqUpstreamProvider
|
||||
from .ollama import OllamaUpstreamProvider
|
||||
from .openai import OpenAIUpstreamProvider
|
||||
from .openrouter import OpenRouterUpstreamProvider
|
||||
from .perplexity import PerplexityUpstreamProvider
|
||||
from .ppqai import PPQAIUpstreamProvider
|
||||
from .xai import XAIUpstreamProvider
|
||||
|
||||
upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
AnthropicUpstreamProvider,
|
||||
AzureUpstreamProvider,
|
||||
FireworksUpstreamProvider,
|
||||
GeminiUpstreamProvider,
|
||||
GenericUpstreamProvider,
|
||||
GroqUpstreamProvider,
|
||||
OllamaUpstreamProvider,
|
||||
OpenAIUpstreamProvider,
|
||||
OpenRouterUpstreamProvider,
|
||||
PerplexityUpstreamProvider,
|
||||
PPQAIUpstreamProvider,
|
||||
XAIUpstreamProvider,
|
||||
]
|
||||
"""List of all upstream classes"""
|
||||
|
||||
+1445
-195
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
from .gemini import GeminiClient
|
||||
|
||||
__all__ = ["GeminiClient"]
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
|
||||
class BaseAPIClient(ABC):
|
||||
"""Base class for AI provider API clients."""
|
||||
|
||||
def __init__(self, api_key: str, base_url: str | None = None):
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
|
||||
@abstractmethod
|
||||
async def generate_content(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate content non-streaming."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def generate_content_stream(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_models(self) -> list[dict[str, Any]]:
|
||||
"""List available models."""
|
||||
pass
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from .base import BaseAPIClient
|
||||
|
||||
|
||||
class GeminiClient(BaseAPIClient):
|
||||
"""Gemini API client using OpenAI compatibility layer."""
|
||||
|
||||
def __init__(self, api_key: str, base_url: str | None = None):
|
||||
super().__init__(api_key, base_url)
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url
|
||||
or "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
)
|
||||
|
||||
async def generate_content(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
from openai import NOT_GIVEN
|
||||
|
||||
response = await self.client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages, # type: ignore
|
||||
temperature=temperature if temperature is not None else NOT_GIVEN,
|
||||
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
|
||||
top_p=kwargs.get("top_p", NOT_GIVEN),
|
||||
)
|
||||
return response.model_dump()
|
||||
|
||||
async def generate_content_stream(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
from openai import NOT_GIVEN
|
||||
|
||||
usage_callback = kwargs.get("usage_callback")
|
||||
completion_callback = kwargs.get("completion_callback")
|
||||
|
||||
stream = await self.client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages, # type: ignore
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
temperature=temperature if temperature is not None else NOT_GIVEN,
|
||||
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
|
||||
top_p=kwargs.get("top_p", NOT_GIVEN),
|
||||
)
|
||||
|
||||
final_usage = None
|
||||
|
||||
async for chunk in stream:
|
||||
chunk_data = chunk.model_dump()
|
||||
|
||||
if chunk.usage:
|
||||
final_usage = chunk.usage.model_dump()
|
||||
if usage_callback:
|
||||
usage_callback(final_usage)
|
||||
|
||||
yield chunk_data
|
||||
|
||||
if completion_callback:
|
||||
await completion_callback(model, final_usage)
|
||||
|
||||
async def list_models(self) -> list[dict[str, Any]]:
|
||||
"""List available Gemini models."""
|
||||
try:
|
||||
response = await self.client.models.list()
|
||||
return [model.model_dump() for model in response.data]
|
||||
except Exception as e:
|
||||
from ...core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
logger.error(f"Failed to list Gemini models: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,319 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
from .clients.gemini import GeminiClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
|
||||
from ..core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GeminiUpstreamProvider(BaseUpstreamProvider):
|
||||
provider_type = "gemini"
|
||||
default_base_url = "https://generativelanguage.googleapis.com/v1beta"
|
||||
platform_url = "https://aistudio.google.com/app/apikey"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "https://generativelanguage.googleapis.com/v1beta",
|
||||
api_key: str = "",
|
||||
provider_fee: float = 1.01,
|
||||
):
|
||||
super().__init__(
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
base_url=base_url,
|
||||
)
|
||||
self._client: GeminiClient | None = None
|
||||
|
||||
@property
|
||||
def client(self) -> GeminiClient:
|
||||
"""Get or create the Gemini API client."""
|
||||
if self._client is None:
|
||||
self._client = GeminiClient(api_key=self.api_key)
|
||||
return self._client
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "GeminiUpstreamProvider":
|
||||
return cls(
|
||||
base_url=provider_row.base_url,
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Google Gemini",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
return model_id.removeprefix("gemini/")
|
||||
|
||||
async def forward_request(
|
||||
self,
|
||||
request: Request,
|
||||
path: str,
|
||||
headers: dict,
|
||||
request_body: bytes | None,
|
||||
key: ApiKey,
|
||||
max_cost_for_model: int,
|
||||
session: AsyncSession,
|
||||
model_obj: Model,
|
||||
) -> Response | StreamingResponse:
|
||||
# Remove provider prefix from model ID for Gemini API
|
||||
if "/" in model_obj.id:
|
||||
model_obj.id = model_obj.id.split("/", 1)[1]
|
||||
|
||||
if not path.startswith("chat/completions"):
|
||||
return await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
|
||||
if not request_body:
|
||||
return await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
|
||||
try:
|
||||
openai_data = json.loads(request_body)
|
||||
messages = openai_data.get("messages", [])
|
||||
temperature = openai_data.get("temperature")
|
||||
max_tokens = openai_data.get("max_tokens")
|
||||
top_p = openai_data.get("top_p")
|
||||
is_streaming = openai_data.get("stream", False)
|
||||
|
||||
logger.info(
|
||||
"Processing Gemini request with client abstraction",
|
||||
extra={
|
||||
"model": model_obj.id,
|
||||
"is_streaming": is_streaming,
|
||||
"message_count": len(messages),
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
if is_streaming:
|
||||
final_usage_data: dict | None = None
|
||||
|
||||
def usage_callback(usage_data: dict[str, Any]) -> None:
|
||||
"""Callback to capture usage data during streaming"""
|
||||
nonlocal final_usage_data
|
||||
final_usage_data = usage_data
|
||||
|
||||
async def completion_callback(
|
||||
model: str, usage_data: dict[str, Any] | None
|
||||
) -> None:
|
||||
"""Callback to handle payment when streaming completes"""
|
||||
nonlocal final_usage_data
|
||||
if usage_data:
|
||||
final_usage_data = usage_data
|
||||
|
||||
payment_data = {
|
||||
"model": model,
|
||||
"usage": final_usage_data,
|
||||
}
|
||||
|
||||
from ..auth import adjust_payment_for_tokens
|
||||
from ..core.db import create_session
|
||||
|
||||
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,
|
||||
payment_data,
|
||||
new_session,
|
||||
max_cost_for_model,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Gemini streaming payment finalized",
|
||||
extra={
|
||||
"cost_data": cost_data,
|
||||
"usage_data": final_usage_data,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
except Exception as cost_error:
|
||||
logger.error(
|
||||
"Error finalizing Gemini streaming payment",
|
||||
extra={
|
||||
"error": str(cost_error),
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
response_generator = self.client.generate_content_stream(
|
||||
model=model_obj.id,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
usage_callback=usage_callback,
|
||||
completion_callback=completion_callback,
|
||||
)
|
||||
|
||||
async def stream_with_cost() -> AsyncGenerator[bytes, None]:
|
||||
payment_finalized = False
|
||||
|
||||
async def finalize_payment() -> None:
|
||||
nonlocal payment_finalized
|
||||
if payment_finalized:
|
||||
return
|
||||
from ..auth import adjust_payment_for_tokens
|
||||
from ..core.db import create_session
|
||||
|
||||
async with create_session() as new_session:
|
||||
fresh_key = await new_session.get(
|
||||
key.__class__, key.hashed_key
|
||||
)
|
||||
if fresh_key:
|
||||
try:
|
||||
await adjust_payment_for_tokens(
|
||||
fresh_key,
|
||||
{
|
||||
"model": model_obj.id,
|
||||
"usage": final_usage_data,
|
||||
},
|
||||
new_session,
|
||||
max_cost_for_model,
|
||||
)
|
||||
payment_finalized = True
|
||||
except Exception as cost_error:
|
||||
logger.error(
|
||||
"Error finalizing Gemini streaming payment in fallback",
|
||||
extra={
|
||||
"error": str(cost_error),
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
async for chunk in response_generator:
|
||||
sse_data = f"data: {json.dumps(chunk)}\n\n"
|
||||
yield sse_data.encode()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error in Gemini streaming response",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if not payment_finalized:
|
||||
await finalize_payment()
|
||||
|
||||
return StreamingResponse(
|
||||
stream_with_cost(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
||||
)
|
||||
|
||||
else:
|
||||
openai_format_response = await self.client.generate_content(
|
||||
model=model_obj.id,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
)
|
||||
|
||||
from ..auth import adjust_payment_for_tokens
|
||||
|
||||
cost_data = await adjust_payment_for_tokens(
|
||||
key, openai_format_response, session, max_cost_for_model
|
||||
)
|
||||
openai_format_response["cost"] = cost_data
|
||||
|
||||
logger.info(
|
||||
"Gemini non-streaming payment completed",
|
||||
extra={
|
||||
"cost_data": cost_data,
|
||||
"model": model_obj.id,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=json.dumps(openai_format_response),
|
||||
media_type="application/json",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error in Gemini forward_request",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"path": path,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
return await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
|
||||
async def _fetch_provider_models(self) -> dict:
|
||||
"""Fetch models from Gemini API."""
|
||||
try:
|
||||
models_data = await self.client.list_models()
|
||||
|
||||
for model in models_data:
|
||||
if "id" in model and model["id"].startswith("models/"):
|
||||
model["id"] = model["id"].removeprefix("models/")
|
||||
|
||||
return {"data": models_data}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to fetch models from Gemini API: {e}",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"base_url": self.base_url,
|
||||
},
|
||||
)
|
||||
return {"data": []}
|
||||
+47
-21
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -7,6 +8,7 @@ from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from ..core.settings import Settings
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
|
||||
@@ -100,6 +102,8 @@ async def get_all_models_with_overrides(
|
||||
)
|
||||
for row in override_rows
|
||||
if row.upstream_provider_id is not None
|
||||
and row.upstream_provider_id in providers_by_id
|
||||
and providers_by_id[row.upstream_provider_id].enabled
|
||||
}
|
||||
|
||||
all_models: dict[str, Model] = {}
|
||||
@@ -145,6 +149,17 @@ async def refresh_upstreams_models_periodically(
|
||||
f"Error refreshing models for {upstream.base_url}",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
try:
|
||||
from ..payment.models import _update_sats_pricing_once
|
||||
|
||||
await _update_sats_pricing_once()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update pricing after model refresh: {e}")
|
||||
from ..proxy import refresh_model_maps
|
||||
|
||||
await refresh_model_maps()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
@@ -166,8 +181,6 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
||||
Seeds database with providers from settings if empty, then loads and instantiates
|
||||
provider instances from database records, and refreshes their models cache.
|
||||
"""
|
||||
from sqlmodel import select
|
||||
|
||||
from ..core.settings import settings
|
||||
|
||||
async with create_session() as session:
|
||||
@@ -183,23 +196,29 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
||||
result = await session.exec(select(UpstreamProviderRow))
|
||||
existing_providers = result.all()
|
||||
|
||||
upstreams: list[BaseUpstreamProvider] = []
|
||||
for provider_row in existing_providers:
|
||||
async def _init_single_provider(
|
||||
provider_row: UpstreamProviderRow,
|
||||
) -> BaseUpstreamProvider | None:
|
||||
if not provider_row.enabled:
|
||||
logger.debug(f"Skipping disabled provider: {provider_row.base_url}")
|
||||
continue
|
||||
return None
|
||||
|
||||
provider = _instantiate_provider(provider_row)
|
||||
if provider:
|
||||
await provider.refresh_models_cache()
|
||||
upstreams.append(provider)
|
||||
logger.info(
|
||||
logger.debug(
|
||||
f"Initialized {provider_row.provider_type} provider",
|
||||
extra={
|
||||
"base_url": provider_row.base_url,
|
||||
"models_cached": len(provider.get_cached_models()),
|
||||
},
|
||||
)
|
||||
return provider
|
||||
return None
|
||||
|
||||
tasks = [_init_single_provider(row) for row in existing_providers]
|
||||
results = await asyncio.gather(*tasks)
|
||||
upstreams = [p for p in results if p is not None]
|
||||
|
||||
return upstreams
|
||||
|
||||
@@ -217,7 +236,7 @@ async def _seed_providers_from_settings(
|
||||
from . import upstream_provider_classes
|
||||
|
||||
providers_to_add: list[UpstreamProviderRow] = []
|
||||
seeded_base_urls: set[str] = set()
|
||||
seeded_provider_keys: set[tuple[str, str]] = set()
|
||||
|
||||
provider_classes_by_type = {
|
||||
cls.provider_type: cls
|
||||
@@ -242,7 +261,8 @@ async def _seed_providers_from_settings(
|
||||
base_url = provider_class.default_base_url # type: ignore[attr-defined]
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == base_url
|
||||
UpstreamProviderRow.base_url == base_url,
|
||||
UpstreamProviderRow.api_key == api_key,
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
@@ -254,13 +274,15 @@ async def _seed_providers_from_settings(
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(base_url)
|
||||
seeded_provider_keys.add((base_url, api_key))
|
||||
|
||||
ollama_base_url = os.environ.get("OLLAMA_BASE_URL")
|
||||
if ollama_base_url:
|
||||
ollama_api_key = os.environ.get("OLLAMA_API_KEY", "")
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == ollama_base_url
|
||||
UpstreamProviderRow.base_url == ollama_base_url,
|
||||
UpstreamProviderRow.api_key == ollama_api_key,
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
@@ -268,18 +290,20 @@ async def _seed_providers_from_settings(
|
||||
UpstreamProviderRow(
|
||||
provider_type="ollama",
|
||||
base_url=ollama_base_url,
|
||||
api_key=os.environ.get("OLLAMA_API_KEY", ""),
|
||||
api_key=ollama_api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(ollama_base_url)
|
||||
seeded_provider_keys.add((ollama_base_url, ollama_api_key))
|
||||
|
||||
if settings.chat_completions_api_version and settings.upstream_base_url:
|
||||
base_url = settings.upstream_base_url
|
||||
if base_url not in seeded_base_urls:
|
||||
api_key = settings.upstream_api_key
|
||||
if (base_url, api_key) not in seeded_provider_keys:
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == base_url
|
||||
UpstreamProviderRow.base_url == base_url,
|
||||
UpstreamProviderRow.api_key == api_key,
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
@@ -287,19 +311,21 @@ async def _seed_providers_from_settings(
|
||||
UpstreamProviderRow(
|
||||
provider_type="azure",
|
||||
base_url=base_url,
|
||||
api_key=settings.upstream_api_key,
|
||||
api_key=api_key,
|
||||
api_version=settings.chat_completions_api_version,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(base_url)
|
||||
seeded_provider_keys.add((base_url, api_key))
|
||||
|
||||
if settings.upstream_base_url and settings.upstream_api_key:
|
||||
base_url = settings.upstream_base_url
|
||||
if base_url not in seeded_base_urls:
|
||||
api_key = settings.upstream_api_key
|
||||
if (base_url, api_key) not in seeded_provider_keys:
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == base_url
|
||||
UpstreamProviderRow.base_url == base_url,
|
||||
UpstreamProviderRow.api_key == api_key,
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
@@ -307,11 +333,11 @@ async def _seed_providers_from_settings(
|
||||
UpstreamProviderRow(
|
||||
provider_type="custom",
|
||||
base_url=base_url,
|
||||
api_key=settings.upstream_api_key,
|
||||
api_key=api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(base_url)
|
||||
seeded_provider_keys.add((base_url, api_key))
|
||||
|
||||
for provider in providers_to_add:
|
||||
session.add(provider)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
@@ -42,9 +44,39 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
"can_show_balance": True,
|
||||
}
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch all OpenRouter models."""
|
||||
models_data = await async_fetch_openrouter_models()
|
||||
return [Model(**model) for model in models_data] # type: ignore
|
||||
models = [Model(**model) for model in models_data] # type: ignore
|
||||
# manual alias for openai/text-embedding-ada-002 due to openrouter api bug
|
||||
for model in models:
|
||||
if model.id == "openai/text-embedding-ada-002":
|
||||
model.alias_ids = ["text-embedding-ada-002-v2"]
|
||||
break
|
||||
return models
|
||||
|
||||
async def get_balance(self) -> float | None:
|
||||
"""Get the current account balance from OpenRouter.
|
||||
|
||||
Returns:
|
||||
Float representing the balance amount (in credits/USD), or None if unavailable.
|
||||
"""
|
||||
url = f"{self.base_url}/credits"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
credits_data = data.get("data", {})
|
||||
total_credits = float(credits_data.get("total_credits", 0.0))
|
||||
total_usage = float(credits_data.get("total_usage", 0.0))
|
||||
|
||||
return total_credits - total_usage
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..core.logging import get_logger
|
||||
from ..payment.models import Architecture, Model, Pricing, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider, TopupData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PPQAIModelPricing(BaseModel):
|
||||
ui: dict[str, float]
|
||||
api: dict[str, float]
|
||||
|
||||
|
||||
class PPQAIModel(BaseModel):
|
||||
id: str
|
||||
provider: str
|
||||
name: str
|
||||
created_at: int
|
||||
context_length: int
|
||||
pricing: PPQAIModelPricing
|
||||
popular: bool
|
||||
|
||||
|
||||
class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider for PPQ.AI API with Lightning Network top-up support."""
|
||||
|
||||
provider_type = "ppqai"
|
||||
default_base_url = "https://api.ppq.ai"
|
||||
platform_url = "https://ppq.ai/api-docs"
|
||||
IGNORED_MODEL_IDS: list[str] = ["auto"]
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.0):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "PPQAIUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "PPQ.AI",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
"can_create_account": True,
|
||||
"can_topup": True,
|
||||
"can_show_balance": True,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
return model_id
|
||||
|
||||
@classmethod
|
||||
async def create_account_static(cls) -> dict[str, object]:
|
||||
"""Create a new PPQ.AI account without requiring an instance.
|
||||
|
||||
Returns:
|
||||
Dict containing 'credit_id' and 'api_key' for the new account.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{cls.default_base_url}/accounts/create"
|
||||
|
||||
logger.info("Creating new PPQ.AI account", extra={"url": url})
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url)
|
||||
response.raise_for_status()
|
||||
account_data = response.json()
|
||||
|
||||
logger.info(
|
||||
"Successfully created PPQ.AI account",
|
||||
extra={
|
||||
"credit_id": account_data.get("credit_id"),
|
||||
"has_api_key": bool(account_data.get("api_key")),
|
||||
},
|
||||
)
|
||||
|
||||
return account_data
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from PPQ.AI API."""
|
||||
url = f"{self.base_url}/models"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
models_data = data.get("data", [])
|
||||
|
||||
or_models = [
|
||||
Model(**model) # type: ignore
|
||||
for model in await async_fetch_openrouter_models()
|
||||
]
|
||||
|
||||
models = []
|
||||
for model_data in models_data:
|
||||
try:
|
||||
ppqai_model = PPQAIModel.parse_obj(model_data)
|
||||
if ppqai_model.id in self.IGNORED_MODEL_IDS:
|
||||
continue
|
||||
|
||||
or_model = next(
|
||||
(
|
||||
model
|
||||
for model in or_models
|
||||
if (model.id == ppqai_model.id)
|
||||
or (model.id.split("/")[-1] == ppqai_model.id)
|
||||
or (model.id == ppqai_model.id.split("/")[-1])
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if or_model:
|
||||
if input_price := ppqai_model.pricing.api.get(
|
||||
"input_per_1M"
|
||||
):
|
||||
or_model.pricing.prompt = input_price / 1_000_000
|
||||
if output_price := ppqai_model.pricing.api.get(
|
||||
"output_per_1M"
|
||||
):
|
||||
or_model.pricing.completion = output_price / 1_000_000
|
||||
if cl := ppqai_model.context_length:
|
||||
or_model.context_length = cl
|
||||
models.append(or_model)
|
||||
else:
|
||||
input_price = ppqai_model.pricing.api.get(
|
||||
"input_per_1M", 0.0
|
||||
)
|
||||
output_price = ppqai_model.pricing.api.get(
|
||||
"output_per_1M", 0.0
|
||||
)
|
||||
|
||||
models.append(
|
||||
Model(
|
||||
id=ppqai_model.id,
|
||||
name=ppqai_model.name,
|
||||
created=ppqai_model.created_at // 1000,
|
||||
description=f"{ppqai_model.provider} model",
|
||||
context_length=ppqai_model.context_length,
|
||||
architecture=Architecture(
|
||||
modality="text->text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="Unknown",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=Pricing(
|
||||
prompt=input_price / 1_000_000,
|
||||
completion=output_price / 1_000_000,
|
||||
request=0.0,
|
||||
image=0.0,
|
||||
web_search=0.0,
|
||||
internal_reasoning=0.0,
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to parse PPQ.AI model",
|
||||
extra={
|
||||
"model_id": model_data.get("id", "unknown"),
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
return models
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error fetching models from PPQ.AI",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
return []
|
||||
|
||||
async def on_upstream_error_redirect(
|
||||
self, status_code: int, error_message: str
|
||||
) -> None:
|
||||
if "insufficient balance" in error_message.lower():
|
||||
logger.warning(
|
||||
f"Disabling PPQ.AI provider ({self.base_url}) due to insufficient balance",
|
||||
extra={"error": error_message},
|
||||
)
|
||||
from sqlmodel import select
|
||||
|
||||
from ..core.db import UpstreamProviderRow, create_session
|
||||
|
||||
async with create_session() as session:
|
||||
statement = select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == self.base_url,
|
||||
UpstreamProviderRow.api_key == self.api_key,
|
||||
)
|
||||
result = await session.exec(statement)
|
||||
provider = result.first()
|
||||
|
||||
if provider:
|
||||
provider.enabled = False
|
||||
session.add(provider)
|
||||
await session.commit()
|
||||
|
||||
# Trigger re-initialization of providers
|
||||
# Import here to avoid circular dependency
|
||||
from ..proxy import reinitialize_upstreams
|
||||
|
||||
await reinitialize_upstreams()
|
||||
|
||||
async def create_account(self) -> dict[str, object]:
|
||||
"""Create a new PPQ.AI account.
|
||||
|
||||
Returns:
|
||||
Dict containing 'credit_id' and 'api_key' for the new account.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/accounts/create"
|
||||
|
||||
logger.info("Creating new PPQ.AI account", extra={"url": url})
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url)
|
||||
response.raise_for_status()
|
||||
account_data = response.json()
|
||||
|
||||
logger.info(
|
||||
"Successfully created PPQ.AI account",
|
||||
extra={
|
||||
"credit_id": account_data.get("credit_id"),
|
||||
"has_api_key": bool(account_data.get("api_key")),
|
||||
},
|
||||
)
|
||||
|
||||
return account_data
|
||||
|
||||
async def create_lightning_topup(
|
||||
self, amount: int, currency: str
|
||||
) -> dict[str, object]:
|
||||
"""Create a Lightning Network top-up invoice for this account.
|
||||
|
||||
Args:
|
||||
amount: Amount to top up (in the specified currency)
|
||||
currency: Currency for the top-up (default: "USD")
|
||||
|
||||
Returns:
|
||||
Dict containing invoice details including 'invoice_id', 'payment_request', etc.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/topup/create/btc-lightning"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {"amount": amount, "currency": currency}
|
||||
|
||||
logger.info(
|
||||
"Creating Lightning top-up invoice",
|
||||
extra={"url": url, "amount": amount, "currency": currency},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
print(f"Payload: {payload}", "sending to", url)
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
invoice_data = response.json()
|
||||
|
||||
logger.info(
|
||||
"Successfully created Lightning top-up invoice",
|
||||
extra={
|
||||
"invoice_id": invoice_data.get("invoice_id"),
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
},
|
||||
)
|
||||
|
||||
return invoice_data
|
||||
|
||||
async def check_topup_status(self, invoice_id: str) -> bool:
|
||||
"""Check the status of a Lightning top-up invoice.
|
||||
|
||||
Args:
|
||||
invoice_id: The invoice ID to check
|
||||
|
||||
Returns:
|
||||
True if the invoice is paid (status == "Settled"), False otherwise
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/topup/status/{invoice_id}"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
logger.debug(
|
||||
"Checking Lightning top-up status",
|
||||
extra={"url": url, "invoice_id": invoice_id},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
status_data = response.json()
|
||||
|
||||
is_paid = status_data.get("status") == "Settled"
|
||||
|
||||
logger.debug(
|
||||
"Retrieved Lightning top-up status",
|
||||
extra={
|
||||
"invoice_id": invoice_id,
|
||||
"status": status_data.get("status"),
|
||||
"is_paid": is_paid,
|
||||
},
|
||||
)
|
||||
|
||||
return is_paid
|
||||
|
||||
async def initiate_topup(self, amount: int) -> TopupData:
|
||||
"""Initiate a Lightning Network top-up for the PPQ.AI account.
|
||||
|
||||
Args:
|
||||
amount: Amount in currency units to top up (will be sent to PPQ.AI API)
|
||||
|
||||
Returns:
|
||||
TopupData with standardized invoice information
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails
|
||||
"""
|
||||
ppq_response = await self.create_lightning_topup(amount, "USD")
|
||||
|
||||
logger.info(
|
||||
"PPQ.AI top-up response",
|
||||
extra={
|
||||
"ppq_response": ppq_response,
|
||||
"invoice_id": ppq_response.get("invoice_id"),
|
||||
"has_lightning_invoice": "lightning_invoice" in ppq_response,
|
||||
},
|
||||
)
|
||||
|
||||
expires_at_value = ppq_response.get("expires_at")
|
||||
checkout_url_value = ppq_response.get("checkout_url")
|
||||
|
||||
topup_data = TopupData(
|
||||
invoice_id=str(ppq_response["invoice_id"]),
|
||||
payment_request=str(ppq_response["lightning_invoice"]),
|
||||
amount=int(ppq_response["amount"])
|
||||
if isinstance(ppq_response["amount"], (int, float, str))
|
||||
else 0,
|
||||
currency=str(ppq_response["currency"]),
|
||||
expires_at=int(expires_at_value)
|
||||
if isinstance(expires_at_value, (int, float, str))
|
||||
and expires_at_value is not None
|
||||
else None,
|
||||
checkout_url=str(checkout_url_value)
|
||||
if checkout_url_value is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Created TopupData",
|
||||
extra={
|
||||
"invoice_id": topup_data.invoice_id,
|
||||
"payment_request_length": len(topup_data.payment_request),
|
||||
"amount": topup_data.amount,
|
||||
},
|
||||
)
|
||||
|
||||
return topup_data
|
||||
|
||||
async def get_balance(self) -> float | None:
|
||||
"""Get the current account balance from PPQ.AI.
|
||||
|
||||
Returns:
|
||||
Float representing the balance amount (in USD), or None if unavailable.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails
|
||||
"""
|
||||
data = await self.check_balance()
|
||||
balance = data.get("balance")
|
||||
if isinstance(balance, (int, float)):
|
||||
return float(balance)
|
||||
return None
|
||||
|
||||
async def check_balance(self) -> dict[str, object]:
|
||||
"""Check the account balance for this PPQ.AI account.
|
||||
|
||||
Returns:
|
||||
Dict containing balance information
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/credits/balance"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
logger.debug("Checking PPQ.AI account balance", extra={"url": url})
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, headers=headers, json={})
|
||||
response.raise_for_status()
|
||||
balance_data = response.json()
|
||||
|
||||
logger.debug(
|
||||
"Retrieved PPQ.AI account balance",
|
||||
extra={"balance": balance_data.get("balance")},
|
||||
)
|
||||
|
||||
return balance_data
|
||||
+1
-1
@@ -81,7 +81,7 @@ async def swap_to_primary_mint(
|
||||
amount_msat = token_amount
|
||||
else:
|
||||
raise ValueError("Invalid unit")
|
||||
estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2))
|
||||
estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2)) + 1
|
||||
amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000
|
||||
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = input("Enter routstr URL: ")
|
||||
API_KEY = input("Enter key or token: ")
|
||||
|
||||
|
||||
async def get_balance(client: httpx.AsyncClient) -> int:
|
||||
response = await client.get("/v1/balance/info")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
print(f"Current Balance Info: {data}")
|
||||
return data.get("reserved", 0)
|
||||
|
||||
|
||||
async def reproduce() -> None:
|
||||
headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=BASE_URL, headers=headers, timeout=30.0
|
||||
) as client:
|
||||
print("Checking initial balance...")
|
||||
try:
|
||||
initial_reserved = await get_balance(client)
|
||||
except Exception as e:
|
||||
print(f"Failed to get balance: {e}")
|
||||
return
|
||||
|
||||
print("\nStarting streaming request...")
|
||||
try:
|
||||
# Create a separate client for the stream so we can close it independently if needed,
|
||||
# but usually just breaking the loop and exiting the context manager is enough.
|
||||
# However, to be sure we simulate a harsh disconnect, we can just cancel the task or close the client.
|
||||
|
||||
async with client.stream(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "gpt-5-nano",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Write a long poem about the ocean.",
|
||||
}
|
||||
],
|
||||
"stream": True,
|
||||
},
|
||||
) as response:
|
||||
print(f"Stream status: {response.status_code}")
|
||||
if response.status_code != 200:
|
||||
err_bytes = await response.aread()
|
||||
try:
|
||||
err_str = err_bytes.decode()
|
||||
except Exception:
|
||||
err_str = repr(err_bytes)
|
||||
print(f"Error: {err_str}")
|
||||
return
|
||||
|
||||
print("Stream started. Reading a few chunks...")
|
||||
count = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
print(f"Received chunk: {len(chunk)} bytes")
|
||||
count += 1
|
||||
if count >= 3:
|
||||
print("Simulating client disconnect (breaking stream)...")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"Stream interrupted (expected): {e}")
|
||||
|
||||
# Wait a bit for the server to realize we disconnected (though with asyncio it might be immediate or depend on keepalive)
|
||||
print("\nWaiting for server to process disconnect...")
|
||||
await asyncio.sleep(21)
|
||||
|
||||
print("\nChecking final balance...")
|
||||
try:
|
||||
final_reserved = await get_balance(client)
|
||||
except Exception:
|
||||
# Retry once if connection was closed
|
||||
async with httpx.AsyncClient(
|
||||
base_url=BASE_URL, headers=headers, timeout=30.0
|
||||
) as new_client:
|
||||
final_reserved = await get_balance(new_client)
|
||||
|
||||
if final_reserved > initial_reserved:
|
||||
print(
|
||||
f"\n[FAIL] Bug reproduced! Reserved balance increased: {initial_reserved} -> {final_reserved}"
|
||||
)
|
||||
print(f"Accumulated reserved balance: {final_reserved - initial_reserved}")
|
||||
else:
|
||||
print(
|
||||
f"\n[PASS] Reserved balance released correctly: {initial_reserved} -> {final_reserved}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(reproduce())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -0,0 +1,141 @@
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
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 ChildKeyRequest, create_child_key
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.core.settings import settings
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_child_key_flow(integration_session: AsyncSession) -> None:
|
||||
# 1. Create a parent key with balance
|
||||
parent_raw = "parent_test_key_" + secrets.token_hex(4)
|
||||
parent_key = ApiKey(
|
||||
hashed_key=parent_raw,
|
||||
balance=10000, # 10 sats
|
||||
)
|
||||
integration_session.add(parent_key)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(parent_key)
|
||||
|
||||
# Mock settings
|
||||
settings.child_key_cost = 1000 # 1 sat
|
||||
|
||||
# 2. Call create_child_key
|
||||
result = await create_child_key(
|
||||
ChildKeyRequest(count=1), parent_key, integration_session
|
||||
)
|
||||
|
||||
assert "api_keys" in result
|
||||
assert result["cost_msats"] == 1000
|
||||
assert result["parent_balance"] == 9000
|
||||
|
||||
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)
|
||||
assert child_key_db is not None
|
||||
assert child_key_db.parent_key_hash == parent_key.hashed_key
|
||||
assert child_key_db.balance == 0
|
||||
|
||||
# 4. Test payment with child key
|
||||
cost = 500
|
||||
await pay_for_request(child_key_db, cost, integration_session)
|
||||
|
||||
# Refresh keys
|
||||
await integration_session.refresh(parent_key)
|
||||
await integration_session.refresh(child_key_db)
|
||||
|
||||
# Parent should be charged
|
||||
assert parent_key.reserved_balance == 500
|
||||
assert parent_key.total_requests == 1
|
||||
|
||||
# Child should have total_requests incremented
|
||||
assert child_key_db.total_requests == 1
|
||||
|
||||
# 5. Test adjustment
|
||||
response_data = {"model": "test-model", "usage": {"total_tokens": 10}}
|
||||
|
||||
# Mock calculate_cost
|
||||
import routstr.auth
|
||||
from routstr.payment.cost_calculation import CostData
|
||||
|
||||
async def mock_calculate_cost(*args: Any, **kwargs: Any) -> CostData:
|
||||
return CostData(
|
||||
base_msats=0, input_msats=200, output_msats=200, total_msats=400
|
||||
)
|
||||
|
||||
# Patch calculate_cost
|
||||
original_calculate_cost = routstr.auth.calculate_cost
|
||||
routstr.auth.calculate_cost = mock_calculate_cost
|
||||
|
||||
try:
|
||||
adjustment = await adjust_payment_for_tokens(
|
||||
child_key_db, response_data, integration_session, 500
|
||||
)
|
||||
assert adjustment["total_msats"] == 400
|
||||
|
||||
# Refresh keys
|
||||
await integration_session.refresh(parent_key)
|
||||
await integration_session.refresh(child_key_db)
|
||||
|
||||
# Parent should have updated balance and total_spent
|
||||
assert parent_key.reserved_balance == 0
|
||||
assert parent_key.balance == 9000 - 400
|
||||
assert (
|
||||
parent_key.total_spent == 1400
|
||||
) # 1000 for child key creation + 400 for request
|
||||
|
||||
# Child should also have total_spent updated
|
||||
assert child_key_db.total_spent == 400
|
||||
|
||||
finally:
|
||||
routstr.auth.calculate_cost = original_calculate_cost
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_child_key_insufficient_balance(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
parent_key = ApiKey(
|
||||
hashed_key="poor_parent_" + secrets.token_hex(4),
|
||||
balance=500,
|
||||
)
|
||||
integration_session.add(parent_key)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(parent_key)
|
||||
|
||||
settings.child_key_cost = 1000
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await create_child_key(
|
||||
ChildKeyRequest(count=1), parent_key, integration_session
|
||||
)
|
||||
assert exc.value.status_code == 402
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_child_key_cannot_create_child(integration_session: AsyncSession) -> None:
|
||||
parent_key = ApiKey(
|
||||
hashed_key="parent_" + secrets.token_hex(4),
|
||||
balance=10000,
|
||||
)
|
||||
child_key = ApiKey(
|
||||
hashed_key="child_" + secrets.token_hex(4),
|
||||
balance=0,
|
||||
parent_key_hash=parent_key.hashed_key,
|
||||
)
|
||||
integration_session.add(parent_key)
|
||||
integration_session.add(child_key)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(child_key)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
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)
|
||||
@@ -0,0 +1,97 @@
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_embeddings_endpoint(authenticated_client: AsyncClient) -> None:
|
||||
"""Test the embeddings endpoint proxy functionality"""
|
||||
|
||||
test_payload = {
|
||||
"model": "text-embedding-ada-002",
|
||||
"input": "The quick brown fox",
|
||||
}
|
||||
|
||||
mock_response_data = {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"object": "embedding", "embedding": [0.0023, -0.0012, 0.0045], "index": 0}
|
||||
],
|
||||
"model": "text-embedding-ada-002",
|
||||
"usage": {"prompt_tokens": 5, "total_tokens": 5},
|
||||
}
|
||||
|
||||
with patch("httpx.AsyncClient.send") as mock_send:
|
||||
# Create a proper async generator for iter_bytes
|
||||
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
|
||||
yield json.dumps(mock_response_data).encode()
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"content-type": "application/json"}
|
||||
mock_response.text = json.dumps(mock_response_data)
|
||||
# Use MagicMock for synchronous .json() method
|
||||
mock_response.json = MagicMock(return_value=mock_response_data)
|
||||
mock_response.iter_bytes = mock_iter_bytes
|
||||
mock_response.aiter_bytes = mock_iter_bytes
|
||||
mock_send.return_value = mock_response
|
||||
|
||||
# Make POST request to embeddings endpoint
|
||||
response = await authenticated_client.post("/v1/embeddings", json=test_payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
response_data = response.json()
|
||||
assert response_data["object"] == "list"
|
||||
assert len(response_data["data"]) == 1
|
||||
assert response_data["data"][0]["object"] == "embedding"
|
||||
|
||||
# Verify request was forwarded
|
||||
mock_send.assert_called_once()
|
||||
forwarded_request = mock_send.call_args[0][0]
|
||||
# Verify the path ends with embeddings
|
||||
# Note: forwarded path might be full URL
|
||||
assert str(forwarded_request.url).endswith("embeddings")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_case_insensitivity(authenticated_client: AsyncClient) -> None:
|
||||
"""Test that model lookups are case insensitive"""
|
||||
|
||||
# We'll use a mixed-case model ID that should match the lowercase one in the system
|
||||
# We assume 'gpt-3.5-turbo' is available in the mock env/database
|
||||
|
||||
test_payload = {
|
||||
"model": "GPT-3.5-TURBO",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
}
|
||||
|
||||
with patch("httpx.AsyncClient.send") as mock_send:
|
||||
mock_response_data = {
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"choices": [{"message": {"content": "Hi"}}],
|
||||
"usage": {"total_tokens": 10},
|
||||
}
|
||||
|
||||
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
|
||||
yield json.dumps(mock_response_data).encode()
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"content-type": "application/json"}
|
||||
mock_response.text = json.dumps(mock_response_data)
|
||||
mock_response.json = MagicMock(return_value=mock_response_data)
|
||||
mock_response.iter_bytes = mock_iter_bytes
|
||||
mock_response.aiter_bytes = mock_iter_bytes
|
||||
mock_send.return_value = mock_response
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/v1/chat/completions", json=test_payload
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -10,7 +10,6 @@ from httpx import AsyncClient
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
PerformanceValidator,
|
||||
ResponseValidator,
|
||||
)
|
||||
|
||||
@@ -159,30 +158,7 @@ async def test_error_handling(
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_performance_requirements(integration_client: AsyncClient) -> None:
|
||||
"""Test that endpoints meet performance requirements"""
|
||||
|
||||
validator = PerformanceValidator()
|
||||
|
||||
# Test info endpoint performance
|
||||
for i in range(50):
|
||||
start = validator.start_timing("info_endpoint")
|
||||
response = await integration_client.get("/")
|
||||
validator.end_timing("info_endpoint", start)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Validate 95th percentile is under 500ms
|
||||
result = validator.validate_response_time(
|
||||
"info_endpoint", max_duration=0.5, percentile=0.95
|
||||
)
|
||||
|
||||
assert result["valid"], (
|
||||
f"Performance requirement failed: "
|
||||
f"95th percentile was {result['percentile_time']:.3f}s "
|
||||
f"(required < {result['max_allowed']}s)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
@@ -379,4 +379,4 @@ async def test_info_endpoints_response_consistency(
|
||||
# Model IDs should be the same
|
||||
first_ids = {m["id"] for m in first_models}
|
||||
response_ids = {m["id"] for m in models}
|
||||
assert first_ids == response_ids
|
||||
assert first_ids == response_ids
|
||||
|
||||
@@ -9,6 +9,7 @@ import gc
|
||||
import statistics
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import patch
|
||||
|
||||
import psutil
|
||||
import pytest
|
||||
@@ -105,82 +106,46 @@ class TestPerformanceBaseline:
|
||||
("GET", "/v1/wallet/info", authenticated_client, None),
|
||||
]
|
||||
|
||||
# Warm up
|
||||
for _ in range(10):
|
||||
await integration_client.get("/")
|
||||
# Enable provider discovery for this test
|
||||
with patch(
|
||||
"routstr.core.settings.settings.providers_refresh_interval_seconds", 300
|
||||
):
|
||||
# Warm up
|
||||
for _ in range(10):
|
||||
await integration_client.get("/")
|
||||
|
||||
# Test each endpoint
|
||||
for method, path, client, data in endpoints:
|
||||
response_times = []
|
||||
# Test each endpoint
|
||||
for method, path, client, data in endpoints:
|
||||
response_times = []
|
||||
|
||||
for i in range(100):
|
||||
start = time.time()
|
||||
for i in range(100):
|
||||
start = time.time()
|
||||
|
||||
if method == "GET":
|
||||
response = await client.get(path)
|
||||
else:
|
||||
response = await client.post(path, json=data)
|
||||
if method == "GET":
|
||||
response = await client.get(path)
|
||||
else:
|
||||
response = await client.post(path, json=data)
|
||||
|
||||
duration = time.time() - start
|
||||
response_times.append(duration * 1000) # Convert to ms
|
||||
duration = time.time() - start
|
||||
response_times.append(duration * 1000) # Convert to ms
|
||||
|
||||
assert response.status_code in [200, 201]
|
||||
assert response.status_code in [200, 201]
|
||||
|
||||
if i % 10 == 0:
|
||||
metrics.record_system_metrics()
|
||||
if i % 10 == 0:
|
||||
metrics.record_system_metrics()
|
||||
|
||||
# Verify 95th percentile < 500ms
|
||||
p95 = sorted(response_times)[int(len(response_times) * 0.95)]
|
||||
assert p95 < 500, (
|
||||
f"{method} {path} p95 response time {p95}ms exceeds 500ms limit"
|
||||
)
|
||||
# Verify 95th percentile < 500ms
|
||||
p95 = sorted(response_times)[int(len(response_times) * 0.95)]
|
||||
assert p95 < 500, (
|
||||
f"{method} {path} p95 response time {p95}ms exceeds 500ms limit"
|
||||
)
|
||||
|
||||
print(f"\n{method} {path}:")
|
||||
print(f" Mean: {statistics.mean(response_times):.2f}ms")
|
||||
print(f" P95: {p95:.2f}ms")
|
||||
print(
|
||||
f" P99: {sorted(response_times)[int(len(response_times) * 0.99)]:.2f}ms"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_query_performance(
|
||||
self, integration_session: Any, db_snapshot: Any
|
||||
) -> None:
|
||||
"""Test database operation performance"""
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
# Create test data
|
||||
for i in range(100):
|
||||
key = ApiKey(
|
||||
hashed_key=f"test_key_{i}",
|
||||
balance=1000000,
|
||||
total_spent=0,
|
||||
total_requests=0,
|
||||
)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
# Test query performance
|
||||
query_times = []
|
||||
|
||||
for _ in range(100):
|
||||
start = time.time()
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.balance > 0) # type: ignore[arg-type]
|
||||
)
|
||||
_ = result.all()
|
||||
duration = (time.time() - start) * 1000
|
||||
query_times.append(duration)
|
||||
|
||||
# All queries should complete < 100ms
|
||||
assert max(query_times) < 100, (
|
||||
f"Max query time {max(query_times)}ms exceeds 100ms limit"
|
||||
)
|
||||
print("\nDatabase query performance:")
|
||||
print(f" Mean: {statistics.mean(query_times):.2f}ms")
|
||||
print(f" Max: {max(query_times):.2f}ms")
|
||||
print(f"\n{method} {path}:")
|
||||
print(f" Mean: {statistics.mean(response_times):.2f}ms")
|
||||
print(f" P95: {p95:.2f}ms")
|
||||
print(
|
||||
f" P99: {sorted(response_times)[int(len(response_times) * 0.99)]:.2f}ms"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
@@ -3,7 +3,7 @@ Integration tests for provider management functionality.
|
||||
Tests GET /v1/providers/ endpoint for listing and managing providers.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -11,7 +11,7 @@ from httpx import AsyncClient
|
||||
|
||||
from routstr.discovery import _PROVIDERS_CACHE
|
||||
|
||||
from .utils import PerformanceValidator, ResponseValidator
|
||||
from .utils import ResponseValidator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -19,6 +19,15 @@ def _clear_providers_cache() -> None:
|
||||
_PROVIDERS_CACHE.clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_provider_discovery() -> Generator[None, Any, Any]:
|
||||
"""Enable provider discovery for all tests in this module"""
|
||||
with patch(
|
||||
"routstr.core.settings.settings.providers_refresh_interval_seconds", 300
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_providers_endpoint_default_response(
|
||||
@@ -518,46 +527,6 @@ async def test_providers_endpoint_response_format(
|
||||
assert isinstance(data_json["providers"], list)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_providers_endpoint_performance(integration_client: AsyncClient) -> None:
|
||||
"""Test providers endpoint meets performance requirements"""
|
||||
|
||||
# Mock quick responses to avoid network delays
|
||||
mock_events: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": f"event{i}",
|
||||
"content": f"Provider: http://provider{i}.onion",
|
||||
"created_at": 1234567890 + i,
|
||||
}
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
validator = PerformanceValidator()
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Test multiple requests
|
||||
for i in range(10):
|
||||
start = validator.start_timing("providers_endpoint")
|
||||
response = await integration_client.get("/v1/providers/")
|
||||
validator.end_timing("providers_endpoint", start)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Validate performance (should be fast with mocked dependencies)
|
||||
perf_result = validator.validate_response_time(
|
||||
"providers_endpoint",
|
||||
max_duration=2.0, # Allow more time since it involves multiple operations
|
||||
percentile=0.95,
|
||||
)
|
||||
assert perf_result["valid"], f"Performance requirement failed: {perf_result}"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_providers_endpoint_concurrent_requests(
|
||||
|
||||
@@ -18,7 +18,6 @@ from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import (
|
||||
ConcurrencyTester,
|
||||
PerformanceValidator,
|
||||
)
|
||||
|
||||
|
||||
@@ -551,39 +550,7 @@ async def test_proxy_get_concurrent_requests(
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_get_performance_requirements(
|
||||
integration_client: AsyncClient, authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test that GET proxy requests meet performance requirements"""
|
||||
|
||||
validator = PerformanceValidator()
|
||||
|
||||
with patch("httpx.AsyncClient.request") as mock_request:
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"content-type": "application/json"}
|
||||
mock_response.json = MagicMock(return_value={"performance": "test"})
|
||||
mock_response.text = '{"performance": "test"}'
|
||||
mock_response.iter_bytes = AsyncMock(return_value=[b'{"performance": "test"}'])
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
# Test multiple requests for performance measurement
|
||||
for i in range(20):
|
||||
start = validator.start_timing("proxy_get")
|
||||
response = await authenticated_client.get(f"/v1/perf-test-{i}")
|
||||
validator.end_timing("proxy_get", start)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Validate performance requirements
|
||||
perf_result = validator.validate_response_time(
|
||||
"proxy_get",
|
||||
max_duration=1.0, # Should complete within 1 second
|
||||
percentile=0.95,
|
||||
)
|
||||
assert perf_result["valid"], f"Performance requirement failed: {perf_result}"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
@@ -15,7 +15,6 @@ from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from .utils import (
|
||||
ConcurrencyTester,
|
||||
PerformanceValidator,
|
||||
)
|
||||
|
||||
|
||||
@@ -290,55 +289,7 @@ async def test_proxy_post_unauthorized_access(integration_client: AsyncClient) -
|
||||
assert response.status_code in [400, 401]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_post_performance(
|
||||
integration_client: AsyncClient, authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test POST endpoint performance requirements"""
|
||||
|
||||
test_payload = {
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "Performance test"}],
|
||||
}
|
||||
|
||||
validator = PerformanceValidator()
|
||||
|
||||
with patch("httpx.AsyncClient.send") as mock_send:
|
||||
# Mock fast responses
|
||||
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
|
||||
yield b'{"choices": [{"message": {"content": "Fast"}}], "usage": {"total_tokens": 5}}'
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"content-type": "application/json"}
|
||||
response_data = {
|
||||
"choices": [{"message": {"content": "Fast"}}],
|
||||
"usage": {"total_tokens": 5},
|
||||
}
|
||||
mock_response.text = json.dumps(response_data)
|
||||
mock_response.json = AsyncMock(return_value=response_data)
|
||||
mock_response.iter_bytes = mock_iter_bytes
|
||||
mock_response.aiter_bytes = mock_iter_bytes
|
||||
mock_send.return_value = mock_response
|
||||
|
||||
# Run multiple requests for performance measurement
|
||||
for i in range(20):
|
||||
start = validator.start_timing("proxy_post")
|
||||
response = await authenticated_client.post(
|
||||
"/v1/chat/completions", json=test_payload
|
||||
)
|
||||
validator.end_timing("proxy_post", start)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Validate performance
|
||||
perf_result = validator.validate_response_time(
|
||||
"proxy_post",
|
||||
max_duration=1.5, # Allow slightly more time for POST
|
||||
percentile=0.95,
|
||||
)
|
||||
assert perf_result["valid"], f"Performance requirement failed: {perf_result}"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
@@ -3,7 +3,6 @@ Integration tests for wallet authentication system including API key generation
|
||||
Tests POST /v1/wallet/topup endpoint and authorization header validation.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -15,7 +14,6 @@ from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
ConcurrencyTester,
|
||||
ResponseValidator,
|
||||
)
|
||||
|
||||
@@ -389,69 +387,6 @@ async def test_api_key_with_expiry_time(
|
||||
# The expiry time and refund address functionality is tested elsewhere
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_token_submissions(
|
||||
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
|
||||
) -> None:
|
||||
"""Test concurrent submissions of different tokens"""
|
||||
|
||||
# Generate multiple unique tokens with known amounts
|
||||
num_tokens = 10
|
||||
tokens = []
|
||||
expected_balances = {}
|
||||
|
||||
for i in range(num_tokens):
|
||||
amount = 100 + i * 10
|
||||
token = await testmint_wallet.mint_tokens(amount)
|
||||
tokens.append(token)
|
||||
# Store expected balance by token hash
|
||||
hashed_key = hashlib.sha256(token.encode()).hexdigest()
|
||||
expected_balances[hashed_key] = amount * 1000 # msats
|
||||
|
||||
# Create concurrent requests
|
||||
requests = [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/v1/wallet/info",
|
||||
"headers": {"Authorization": f"Bearer {token}"},
|
||||
}
|
||||
for token in tokens
|
||||
]
|
||||
|
||||
# Execute concurrently
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=5
|
||||
)
|
||||
|
||||
# All should succeed
|
||||
assert len(responses) == num_tokens
|
||||
api_keys = set()
|
||||
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
api_key = data["api_key"]
|
||||
api_keys.add(api_key)
|
||||
|
||||
# Verify balance matches the expected amount
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
assert data["balance"] == expected_balances[hashed_key]
|
||||
|
||||
# Should have created unique API keys
|
||||
assert len(api_keys) == num_tokens
|
||||
|
||||
# Verify all keys exist in database
|
||||
for api_key in api_keys:
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
assert db_key.balance == expected_balances[hashed_key]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorization_with_cashu_token_directly(
|
||||
@@ -504,48 +439,6 @@ async def test_x_cashu_header_support(
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.slow
|
||||
async def test_api_key_consistency_under_load(
|
||||
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
|
||||
) -> None:
|
||||
"""Test API key generation consistency under concurrent load"""
|
||||
|
||||
# Generate a single token
|
||||
token = await testmint_wallet.mint_tokens(1000)
|
||||
|
||||
# First request to create the API key
|
||||
integration_client.headers["Authorization"] = f"Bearer {token}"
|
||||
initial_response = await integration_client.get("/v1/wallet/info")
|
||||
assert initial_response.status_code == 200
|
||||
expected_api_key = initial_response.json()["api_key"]
|
||||
expected_balance = initial_response.json()["balance"]
|
||||
|
||||
# Try to use the same token concurrently multiple times
|
||||
# All should return the same API key since it's already created
|
||||
requests = [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/v1/wallet/info",
|
||||
"headers": {"Authorization": f"Bearer {token}"},
|
||||
}
|
||||
for _ in range(20) # 20 concurrent attempts
|
||||
]
|
||||
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=10
|
||||
)
|
||||
|
||||
# All should succeed and return the same API key
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["api_key"] == expected_api_key
|
||||
assert data["balance"] == expected_balance
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_timestamp_accuracy(
|
||||
|
||||
@@ -3,7 +3,7 @@ Integration tests for wallet information retrieval endpoints.
|
||||
Tests GET /v1/wallet/ and GET /v1/wallet/info endpoints with various scenarios.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlmodel import select, update
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import ConcurrencyTester, ResponseValidator
|
||||
from .utils import ResponseValidator
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -204,45 +204,6 @@ async def test_expired_api_key_behavior(
|
||||
assert db_key.refund_address == "test@lightning.address"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_access_same_api_key(
|
||||
integration_client: AsyncClient, authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test concurrent access with the same API key"""
|
||||
|
||||
# Get the API key from authenticated client
|
||||
response = await authenticated_client.get("/v1/wallet/")
|
||||
api_key = response.json()["api_key"]
|
||||
initial_balance = response.json()["balance"]
|
||||
|
||||
# Create multiple concurrent requests
|
||||
requests = []
|
||||
for i in range(20):
|
||||
# Alternate between both endpoints
|
||||
endpoint = "/v1/wallet/" if i % 2 == 0 else "/v1/wallet/info"
|
||||
requests.append(
|
||||
{
|
||||
"method": "GET",
|
||||
"url": endpoint,
|
||||
"headers": {"Authorization": f"Bearer {api_key}"},
|
||||
}
|
||||
)
|
||||
|
||||
# Execute concurrently
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=10
|
||||
)
|
||||
|
||||
# All should succeed with consistent data
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["api_key"] == api_key
|
||||
assert data["balance"] == initial_balance
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_info_data_consistency(
|
||||
@@ -406,30 +367,4 @@ async def test_wallet_info_with_special_characters_in_headers(
|
||||
# Note: Current implementation doesn't return refund_address in response
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.slow
|
||||
async def test_wallet_endpoints_performance(authenticated_client: AsyncClient) -> None:
|
||||
"""Test wallet endpoints meet performance requirements"""
|
||||
|
||||
# Warm up
|
||||
await authenticated_client.get("/v1/wallet/")
|
||||
|
||||
# Measure response times
|
||||
response_times = []
|
||||
|
||||
for _ in range(50):
|
||||
start_time = time.time()
|
||||
response = await authenticated_client.get("/v1/wallet/")
|
||||
end_time = time.time()
|
||||
|
||||
assert response.status_code == 200
|
||||
response_times.append(end_time - start_time)
|
||||
|
||||
# Calculate statistics
|
||||
avg_time = sum(response_times) / len(response_times)
|
||||
max_time = max(response_times)
|
||||
|
||||
# Performance assertions
|
||||
assert avg_time < 0.1 # Average should be under 100ms
|
||||
assert max_time < 0.5 # No request should take more than 500ms
|
||||
|
||||
@@ -537,42 +537,4 @@ async def test_refund_with_expired_key(
|
||||
assert response.json()["recipient"] == "expired@ln.address"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.slow
|
||||
async def test_refund_performance(
|
||||
integration_client: AsyncClient, testmint_wallet: Any
|
||||
) -> None:
|
||||
"""Test refund endpoint performance"""
|
||||
|
||||
import time
|
||||
|
||||
# Create multiple API keys
|
||||
api_keys = []
|
||||
for i in range(10):
|
||||
token = await testmint_wallet.mint_tokens(100 + i)
|
||||
# Use cashu token as Bearer auth to create API key
|
||||
integration_client.headers["Authorization"] = f"Bearer {token}"
|
||||
response = await integration_client.get("/v1/wallet/info")
|
||||
assert response.status_code == 200
|
||||
api_keys.append(response.json()["api_key"])
|
||||
|
||||
# Measure refund times
|
||||
refund_times = []
|
||||
|
||||
for api_key in api_keys:
|
||||
integration_client.headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
start_time = time.time()
|
||||
response = await integration_client.post("/v1/wallet/refund")
|
||||
end_time = time.time()
|
||||
|
||||
assert response.status_code == 200
|
||||
refund_times.append(end_time - start_time)
|
||||
|
||||
# Performance assertions
|
||||
avg_time = sum(refund_times) / len(refund_times)
|
||||
max_time = max(refund_times)
|
||||
|
||||
assert avg_time < 0.5 # Average under 500ms
|
||||
assert max_time < 1.0 # No refund takes more than 1 second
|
||||
|
||||
@@ -15,7 +15,6 @@ from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
ConcurrencyTester,
|
||||
ResponseValidator,
|
||||
)
|
||||
|
||||
@@ -284,60 +283,6 @@ async def test_transaction_history_tracking( # type: ignore[no-untyped-def]
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_topups_same_api_key( # type: ignore[no-untyped-def]
|
||||
integration_client: AsyncClient,
|
||||
authenticated_client: AsyncClient,
|
||||
testmint_wallet: Any,
|
||||
) -> None:
|
||||
"""Test concurrent top-ups to the same API key"""
|
||||
|
||||
# Get API key
|
||||
response = await authenticated_client.get("/v1/wallet/")
|
||||
api_key = response.json()["api_key"]
|
||||
initial_balance = response.json()["balance"]
|
||||
|
||||
# Generate multiple unique tokens
|
||||
num_tokens = 10
|
||||
tokens = []
|
||||
total_amount = 0
|
||||
|
||||
for i in range(num_tokens):
|
||||
amount = 100 + i * 10 # Different amounts
|
||||
token = await testmint_wallet.mint_tokens(amount)
|
||||
tokens.append(token)
|
||||
total_amount += amount
|
||||
|
||||
# Create concurrent top-up requests
|
||||
requests = [
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "/v1/wallet/topup",
|
||||
"params": {"cashu_token": token},
|
||||
"headers": {"Authorization": f"Bearer {api_key}"},
|
||||
}
|
||||
for token in tokens
|
||||
]
|
||||
|
||||
# Execute concurrently
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=5
|
||||
)
|
||||
|
||||
# All should succeed
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
assert "msats" in response.json()
|
||||
|
||||
# Verify final balance is correct
|
||||
final_response = await authenticated_client.get("/v1/wallet/")
|
||||
final_balance = final_response.json()["balance"]
|
||||
expected_balance = initial_balance + (total_amount * 1000)
|
||||
assert final_balance == expected_balance
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_during_active_proxy_request( # type: ignore[no-untyped-def]
|
||||
|
||||
@@ -10,7 +10,6 @@ os.environ["UPSTREAM_API_KEY"] = "test"
|
||||
from routstr.algorithm import ( # noqa: E402
|
||||
calculate_model_cost_score,
|
||||
get_provider_penalty,
|
||||
should_prefer_model,
|
||||
)
|
||||
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
|
||||
|
||||
@@ -100,100 +99,3 @@ def test_get_provider_penalty_openrouter() -> None:
|
||||
provider = create_test_provider("openrouter", "https://openrouter.ai/api/v1")
|
||||
penalty = get_provider_penalty(provider)
|
||||
assert penalty == 1.001
|
||||
|
||||
|
||||
def test_should_prefer_model_cheaper_wins() -> None:
|
||||
"""Test that cheaper model is preferred."""
|
||||
cheap_model = create_test_model("cheap", prompt_price=0.001, completion_price=0.002)
|
||||
expensive_model = create_test_model(
|
||||
"expensive", prompt_price=0.03, completion_price=0.06
|
||||
)
|
||||
|
||||
provider1 = create_test_provider("provider1")
|
||||
provider2 = create_test_provider("provider2")
|
||||
|
||||
# Cheaper model should win
|
||||
assert should_prefer_model(
|
||||
cheap_model, provider1, expensive_model, provider2, "test-alias"
|
||||
)
|
||||
|
||||
# More expensive model should not win
|
||||
assert not should_prefer_model(
|
||||
expensive_model, provider2, cheap_model, provider1, "test-alias"
|
||||
)
|
||||
|
||||
|
||||
def test_should_prefer_model_exact_match_wins() -> None:
|
||||
"""Test that exact alias match beats cheaper price."""
|
||||
# Make model IDs match the alias differently
|
||||
exact_match = create_test_model(
|
||||
"test-model", prompt_price=0.03, completion_price=0.06
|
||||
)
|
||||
no_match = create_test_model(
|
||||
"other-model", prompt_price=0.001, completion_price=0.002
|
||||
)
|
||||
|
||||
provider1 = create_test_provider("provider1")
|
||||
provider2 = create_test_provider("provider2")
|
||||
|
||||
# Exact match should win even though it's more expensive
|
||||
assert should_prefer_model(
|
||||
exact_match, provider1, no_match, provider2, "test-model"
|
||||
)
|
||||
|
||||
|
||||
def test_should_prefer_model_openrouter_slight_penalty() -> None:
|
||||
"""Test that OpenRouter has slight penalty compared to other providers."""
|
||||
model1 = create_test_model("model1", prompt_price=0.001, completion_price=0.002)
|
||||
model2 = create_test_model("model2", prompt_price=0.001, completion_price=0.002)
|
||||
|
||||
regular_provider = create_test_provider("regular", "http://provider.com")
|
||||
openrouter_provider = create_test_provider(
|
||||
"openrouter", "https://openrouter.ai/api/v1"
|
||||
)
|
||||
|
||||
# Regular provider should be preferred over OpenRouter at same cost
|
||||
assert should_prefer_model(
|
||||
model1, regular_provider, model2, openrouter_provider, "test-alias"
|
||||
)
|
||||
|
||||
# OpenRouter should not replace regular provider at same cost
|
||||
assert not should_prefer_model(
|
||||
model2, openrouter_provider, model1, regular_provider, "test-alias"
|
||||
)
|
||||
|
||||
|
||||
def test_should_prefer_model_openrouter_can_win_if_cheaper() -> None:
|
||||
"""Test that OpenRouter can still win if significantly cheaper."""
|
||||
cheap_model = create_test_model(
|
||||
"cheap", prompt_price=0.0001, completion_price=0.0002
|
||||
)
|
||||
expensive_model = create_test_model(
|
||||
"expensive", prompt_price=0.03, completion_price=0.06
|
||||
)
|
||||
|
||||
regular_provider = create_test_provider("regular", "http://provider.com")
|
||||
openrouter_provider = create_test_provider(
|
||||
"openrouter", "https://openrouter.ai/api/v1"
|
||||
)
|
||||
|
||||
# OpenRouter should win if it's much cheaper (even with penalty)
|
||||
assert should_prefer_model(
|
||||
cheap_model,
|
||||
openrouter_provider,
|
||||
expensive_model,
|
||||
regular_provider,
|
||||
"test-alias",
|
||||
)
|
||||
|
||||
|
||||
def test_should_prefer_model_same_cost_first_wins() -> None:
|
||||
"""Test that when costs are identical, current model is kept."""
|
||||
model1 = create_test_model("model1", prompt_price=0.001, completion_price=0.002)
|
||||
model2 = create_test_model("model2", prompt_price=0.001, completion_price=0.002)
|
||||
|
||||
provider1 = create_test_provider("provider1")
|
||||
provider2 = create_test_provider("provider2")
|
||||
|
||||
# When costs are equal, should not replace
|
||||
assert not should_prefer_model(model2, provider2, model1, provider1, "test-alias")
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Unit tests for the logging SecurityFilter.
|
||||
|
||||
This module tests that the SecurityFilter correctly identifies and redacts
|
||||
sensitive information from log messages without causing false positives.
|
||||
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.core.logging import SecurityFilter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def security_filter() -> SecurityFilter:
|
||||
"""Provide an instance of the SecurityFilter for testing."""
|
||||
return SecurityFilter()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def filter_message(security_filter: SecurityFilter) -> Callable[[str], str]:
|
||||
"""A helper fixture to apply the filter to a message string."""
|
||||
|
||||
def _filter(msg: str) -> str:
|
||||
record = logging.LogRecord(
|
||||
name="test_logger",
|
||||
level=logging.INFO,
|
||||
pathname="",
|
||||
lineno=0,
|
||||
msg=msg,
|
||||
args=(),
|
||||
exc_info=None,
|
||||
)
|
||||
security_filter.filter(record)
|
||||
return record.getMessage()
|
||||
|
||||
return _filter
|
||||
|
||||
|
||||
def test_redacts_unquoted_key_value_pairs(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that an unquoted key-value pair is correctly redacted."""
|
||||
original = "Processing request with api_key=sk-12345abcdef"
|
||||
expected = "Processing request with api_key: [REDACTED]"
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_redacts_quoted_key_value_pairs(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that a quoted token is correctly redacted."""
|
||||
original = 'User authenticated with token="cashuA123abc"'
|
||||
expected = "User authenticated with token: [REDACTED]"
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_redacts_bearer_token(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that a Bearer token of sufficient length is redacted."""
|
||||
original = "Authorization: Bearer abc1234567890xyzabcdefg"
|
||||
expected = "Authorization: [REDACTED]"
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_redacts_cashu_token(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that a Cashu token is redacted."""
|
||||
original = "Received cashuTOKENeyJ0b2tlbiI6W3siaWQiOiI"
|
||||
expected = "Received [REDACTED]"
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_redacts_nsec_key(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that a full-length Nostr private key is redacted."""
|
||||
original = "Private key is nsec1a8d9f8s7d9f8a7s6d5f4a3s2d1f9a8s7d6f5a4s3d2f1a9s8d7f6a5s4d3f"
|
||||
expected = "Private key is [REDACTED]"
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_ignores_non_sensitive_message(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that a message with no sensitive data is left untouched."""
|
||||
original = "No token pricing configured, using base cost"
|
||||
expected = "No token pricing configured, using base cost"
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_multiple_secrets_in_one_message(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that multiple different secrets in one message are all redacted."""
|
||||
original = 'Auth with Bearer abcdefghijklmnopqrstuvwxyz and api_key="sk-12345"'
|
||||
expected = "Auth with [REDACTED] and api_key: [REDACTED]"
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_redacts_key_with_no_value(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that a key with no value is not redacted."""
|
||||
original = "Request contains api_key and secret."
|
||||
expected = "Request contains api_key and secret."
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_redacts_key_value_with_spaces(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that key-value pairs with extra spaces are correctly redacted."""
|
||||
original = "Auth info: api_key = 'sk-12345'"
|
||||
expected = "Auth info: api_key: [REDACTED]"
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_is_case_insensitive_for_keys(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that key matching is case-insensitive."""
|
||||
original = "TOKEN=sk-abcdef12345"
|
||||
expected = "token: [REDACTED]"
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_is_case_insensitive_for_standalone(
|
||||
filter_message: Callable[[str], str],
|
||||
) -> None:
|
||||
"""Test that standalone matching is case-insensitive."""
|
||||
original = "Using NSEC1a8d9f8s7d9f8a7s6d5f4a3s2d1f9a8s7d6f5a4s3d2f1a9s8d7f6a5s4d3f and CaShuA123abc"
|
||||
expected = "Using [REDACTED] and [REDACTED]"
|
||||
assert filter_message(original) == expected
|
||||
+8
-3
@@ -4,17 +4,22 @@ FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||
RUN npm i
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN npm run build
|
||||
RUN pnpm run build
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+5
-3
@@ -6,12 +6,14 @@ RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* pnpm-lock.yaml* ./
|
||||
RUN npm ci
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build the UI
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
@@ -27,7 +29,7 @@ ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Build the application
|
||||
RUN npm run build && \
|
||||
RUN pnpm run build && \
|
||||
echo "UI build completed at $(date)"
|
||||
|
||||
# Use the builder stage as the final stage
|
||||
|
||||
@@ -29,9 +29,7 @@ export default function BalancesPage() {
|
||||
<div className='container max-w-6xl px-4 py-8 md:px-6 lg:px-8'>
|
||||
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold tracking-tight'>
|
||||
Balances
|
||||
</h1>
|
||||
<h1 className='text-3xl font-bold tracking-tight'>Balances</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Monitor and manage wallet balances
|
||||
</p>
|
||||
|
||||
@@ -78,8 +78,8 @@ export default function AdminLoginPage(): ReactElement {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background px-4 py-12 text-foreground'>
|
||||
<Card className='w-full max-w-md border border-border/60 bg-card/90 shadow-2xl shadow-black/30 backdrop-blur'>
|
||||
<div className='bg-background text-foreground flex min-h-screen items-center justify-center px-4 py-12'>
|
||||
<Card className='border-border/60 bg-card/90 w-full max-w-md border shadow-2xl shadow-black/30 backdrop-blur'>
|
||||
<CardHeader className='space-y-1'>
|
||||
<CardTitle className='text-center text-2xl font-bold'>
|
||||
Admin Login
|
||||
|
||||
@@ -97,7 +97,7 @@ export function LogDetailsDialog({
|
||||
<div>
|
||||
<h4 className='mb-2 text-sm font-medium'>Message</h4>
|
||||
<div className='bg-muted max-h-48 overflow-auto rounded-md p-3'>
|
||||
<pre className='font-mono text-sm whitespace-pre break-all'>
|
||||
<pre className='font-mono text-sm break-all whitespace-pre'>
|
||||
{log.message}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -116,7 +116,12 @@ export function LogDetailsDialog({
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => copyToClipboard(String(log[field as keyof LogEntry] || ''), field)}
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
String(log[field as keyof LogEntry] || ''),
|
||||
field
|
||||
)
|
||||
}
|
||||
className='h-6 flex-shrink-0 px-2'
|
||||
>
|
||||
{copiedField === field ? (
|
||||
@@ -175,7 +180,9 @@ export function LogDetailsDialog({
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => copyToClipboard(JSON.stringify(log, null, 2), 'json')}
|
||||
onClick={() =>
|
||||
copyToClipboard(JSON.stringify(log, null, 2), 'json')
|
||||
}
|
||||
className='h-6 px-2'
|
||||
>
|
||||
{copiedField === 'json' ? (
|
||||
|
||||
+450
-2
@@ -21,7 +21,17 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { CalendarIcon, Filter, X } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CalendarIcon, Filter, X, Plus } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -31,11 +41,17 @@ interface LogFiltersProps {
|
||||
selectedLevel: string;
|
||||
requestId: string;
|
||||
searchText: string;
|
||||
selectedStatusCodes: string[];
|
||||
selectedMethods: string[];
|
||||
selectedEndpoints: string[];
|
||||
limit: number;
|
||||
onDateChange: (date: string) => void;
|
||||
onLevelChange: (level: string) => void;
|
||||
onRequestIdChange: (requestId: string) => void;
|
||||
onSearchTextChange: (searchText: string) => void;
|
||||
onStatusCodesChange: (statusCodes: string[]) => void;
|
||||
onMethodsChange: (methods: string[]) => void;
|
||||
onEndpointsChange: (endpoints: string[]) => void;
|
||||
onLimitChange: (limit: number) => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
@@ -43,16 +59,87 @@ interface LogFiltersProps {
|
||||
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
|
||||
const PRESET_LIMITS = ['25', '50', '100', '200', '500', '1000'];
|
||||
|
||||
const STATUS_CODE_OPTIONS = [
|
||||
'200',
|
||||
'201',
|
||||
'204',
|
||||
'400',
|
||||
'401',
|
||||
'402',
|
||||
'403',
|
||||
'404',
|
||||
'422',
|
||||
'429',
|
||||
'500',
|
||||
'502',
|
||||
'503',
|
||||
'504',
|
||||
];
|
||||
|
||||
const METHOD_OPTIONS = [
|
||||
'GET',
|
||||
'POST',
|
||||
'PUT',
|
||||
'DELETE',
|
||||
'PATCH',
|
||||
'OPTIONS',
|
||||
'HEAD',
|
||||
];
|
||||
|
||||
const ENDPOINT_OPTIONS = [
|
||||
'/chat/completions',
|
||||
'/v1/chat/completions',
|
||||
'/models',
|
||||
'/v1/models',
|
||||
'/responses',
|
||||
'/v1/responses',
|
||||
'v1/embeddings/models',
|
||||
'/embeddings/models',
|
||||
];
|
||||
|
||||
interface FilterBadgeProps {
|
||||
value: string;
|
||||
onRemove: (value: string) => void;
|
||||
}
|
||||
|
||||
function FilterBadge({ value, onRemove }: FilterBadgeProps) {
|
||||
return (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='flex items-center gap-1 px-1 font-normal'
|
||||
>
|
||||
{value}
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRemove(value);
|
||||
}}
|
||||
className='hover:bg-muted-foreground/20 rounded-full'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogFilters({
|
||||
selectedDate,
|
||||
selectedLevel,
|
||||
requestId,
|
||||
searchText,
|
||||
selectedStatusCodes,
|
||||
selectedMethods,
|
||||
selectedEndpoints,
|
||||
limit,
|
||||
onDateChange,
|
||||
onLevelChange,
|
||||
onRequestIdChange,
|
||||
onSearchTextChange,
|
||||
onStatusCodesChange,
|
||||
onMethodsChange,
|
||||
onEndpointsChange,
|
||||
onLimitChange,
|
||||
onClearFilters,
|
||||
}: LogFiltersProps) {
|
||||
@@ -68,6 +155,10 @@ export function LogFilters({
|
||||
: undefined
|
||||
);
|
||||
|
||||
const [statusSearch, setStatusSearch] = useState('');
|
||||
const [methodSearch, setMethodSearch] = useState('');
|
||||
const [endpointSearch, setEndpointSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const currentIsPreset = PRESET_LIMITS.includes(limit.toString());
|
||||
setIsCustom(!currentIsPreset);
|
||||
@@ -129,6 +220,31 @@ export function LogFilters({
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelection = (
|
||||
current: string[],
|
||||
value: string,
|
||||
onChange: (val: string[]) => void
|
||||
) => {
|
||||
if (current.includes(value)) {
|
||||
onChange(current.filter((v) => v !== value));
|
||||
} else {
|
||||
onChange([...current, value]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickStatusCode = (range: '4xx' | '5xx') => {
|
||||
const codes = STATUS_CODE_OPTIONS.filter((c) => c.startsWith(range[0]));
|
||||
const newSelection = new Set([...selectedStatusCodes]);
|
||||
const allIncluded = codes.every((c) => selectedStatusCodes.includes(c));
|
||||
|
||||
if (allIncluded) {
|
||||
codes.forEach((c) => newSelection.delete(c));
|
||||
} else {
|
||||
codes.forEach((c) => newSelection.add(c));
|
||||
}
|
||||
onStatusCodesChange(Array.from(newSelection));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='mb-6'>
|
||||
<CardHeader>
|
||||
@@ -137,7 +253,8 @@ export function LogFilters({
|
||||
Filters
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Filter logs by date, level, request ID, text search, and limit
|
||||
Filter logs by date, level, request ID, text search, status code,
|
||||
method, endpoint and limit
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -197,6 +314,337 @@ export function LogFilters({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label>Status Codes</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full justify-start text-left font-normal'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{selectedStatusCodes.length > 0 ? (
|
||||
selectedStatusCodes.map((code) => (
|
||||
<FilterBadge
|
||||
key={code}
|
||||
value={code}
|
||||
onRemove={(val) =>
|
||||
toggleSelection(
|
||||
selectedStatusCodes,
|
||||
val,
|
||||
onStatusCodesChange
|
||||
)
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground'>All codes</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-64 p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder='Search or add status code...'
|
||||
value={statusSearch}
|
||||
onValueChange={setStatusSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
{selectedStatusCodes.length > 0 && (
|
||||
<CommandGroup heading='Selected'>
|
||||
{selectedStatusCodes.map((code) => (
|
||||
<CommandItem
|
||||
key={`selected-${code}`}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedStatusCodes,
|
||||
code,
|
||||
onStatusCodesChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={true} className='mr-2' />
|
||||
{code}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{statusSearch &&
|
||||
!STATUS_CODE_OPTIONS.includes(statusSearch) &&
|
||||
!selectedStatusCodes.includes(statusSearch) && (
|
||||
<CommandGroup heading='Custom'>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
if (/^\d+$/.test(statusSearch)) {
|
||||
toggleSelection(
|
||||
selectedStatusCodes,
|
||||
statusSearch,
|
||||
onStatusCodesChange
|
||||
);
|
||||
setStatusSearch('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add "{statusSearch}"
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup heading='Quick Filters'>
|
||||
<CommandItem
|
||||
onSelect={() => handleQuickStatusCode('4xx')}
|
||||
>
|
||||
<Checkbox
|
||||
checked={STATUS_CODE_OPTIONS.filter((c) =>
|
||||
c.startsWith('4')
|
||||
).every((c) => selectedStatusCodes.includes(c))}
|
||||
className='mr-2'
|
||||
/>
|
||||
4xx Errors
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => handleQuickStatusCode('5xx')}
|
||||
>
|
||||
<Checkbox
|
||||
checked={STATUS_CODE_OPTIONS.filter((c) =>
|
||||
c.startsWith('5')
|
||||
).every((c) => selectedStatusCodes.includes(c))}
|
||||
className='mr-2'
|
||||
/>
|
||||
5xx Errors
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandGroup heading='Common Codes'>
|
||||
{STATUS_CODE_OPTIONS.filter(
|
||||
(code) => !selectedStatusCodes.includes(code)
|
||||
).map((code) => (
|
||||
<CommandItem
|
||||
key={code}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedStatusCodes,
|
||||
code,
|
||||
onStatusCodesChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={false} className='mr-2' />
|
||||
{code}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label>HTTP Methods</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full justify-start text-left font-normal'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{selectedMethods.length > 0 ? (
|
||||
selectedMethods.map((method) => (
|
||||
<FilterBadge
|
||||
key={method}
|
||||
value={method}
|
||||
onRemove={(val) =>
|
||||
toggleSelection(
|
||||
selectedMethods,
|
||||
val,
|
||||
onMethodsChange
|
||||
)
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground'>All methods</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-64 p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder='Search or add method...'
|
||||
value={methodSearch}
|
||||
onValueChange={setMethodSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
{selectedMethods.length > 0 && (
|
||||
<CommandGroup heading='Selected'>
|
||||
{selectedMethods.map((method) => (
|
||||
<CommandItem
|
||||
key={`selected-${method}`}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedMethods,
|
||||
method,
|
||||
onMethodsChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={true} className='mr-2' />
|
||||
{method}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{methodSearch &&
|
||||
!METHOD_OPTIONS.includes(methodSearch.toUpperCase()) &&
|
||||
!selectedMethods.includes(methodSearch.toUpperCase()) && (
|
||||
<CommandGroup heading='Custom'>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
toggleSelection(
|
||||
selectedMethods,
|
||||
methodSearch.toUpperCase(),
|
||||
onMethodsChange
|
||||
);
|
||||
setMethodSearch('');
|
||||
}}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add "{methodSearch.toUpperCase()}"
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{METHOD_OPTIONS.filter(
|
||||
(method) => !selectedMethods.includes(method)
|
||||
).map((method) => (
|
||||
<CommandItem
|
||||
key={method}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedMethods,
|
||||
method,
|
||||
onMethodsChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={false} className='mr-2' />
|
||||
{method}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label>Endpoints</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full justify-start text-left font-normal'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1 overflow-hidden'>
|
||||
{selectedEndpoints.length > 0 ? (
|
||||
selectedEndpoints.map((endpoint) => (
|
||||
<FilterBadge
|
||||
key={endpoint}
|
||||
value={endpoint}
|
||||
onRemove={(val) =>
|
||||
toggleSelection(
|
||||
selectedEndpoints,
|
||||
val,
|
||||
onEndpointsChange
|
||||
)
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground'>
|
||||
All endpoints
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-80 p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder='Search or add endpoint pattern...'
|
||||
value={endpointSearch}
|
||||
onValueChange={setEndpointSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
{selectedEndpoints.length > 0 && (
|
||||
<CommandGroup heading='Selected'>
|
||||
{selectedEndpoints.map((endpoint) => (
|
||||
<CommandItem
|
||||
key={`selected-${endpoint}`}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedEndpoints,
|
||||
endpoint,
|
||||
onEndpointsChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={true} className='mr-2' />
|
||||
{endpoint}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{endpointSearch &&
|
||||
!ENDPOINT_OPTIONS.includes(endpointSearch) &&
|
||||
!selectedEndpoints.includes(endpointSearch) && (
|
||||
<CommandGroup heading='Custom'>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
toggleSelection(
|
||||
selectedEndpoints,
|
||||
endpointSearch,
|
||||
onEndpointsChange
|
||||
);
|
||||
setEndpointSearch('');
|
||||
}}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add "{endpointSearch}"
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup heading='Common Endpoints'>
|
||||
{ENDPOINT_OPTIONS.filter(
|
||||
(endpoint) => !selectedEndpoints.includes(endpoint)
|
||||
).map((endpoint) => (
|
||||
<CommandItem
|
||||
key={endpoint}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedEndpoints,
|
||||
endpoint,
|
||||
onEndpointsChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={false} className='mr-2' />
|
||||
{endpoint}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='request-id'>Request ID</Label>
|
||||
<Input
|
||||
|
||||
+84
-2
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
@@ -22,15 +22,66 @@ import { LogFilters } from './log-filters';
|
||||
import { LogEntryCard } from './log-entry-card';
|
||||
import { LogDetailsDialog } from './log-details-dialog';
|
||||
|
||||
const STORAGE_KEY = 'routstr-log-filters';
|
||||
|
||||
export default function LogsPage() {
|
||||
const [selectedDate, setSelectedDate] = useState<string>('all');
|
||||
const [selectedLevel, setSelectedLevel] = useState<string>('all');
|
||||
const [requestId, setRequestId] = useState<string>('');
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [selectedStatusCodes, setSelectedStatusCodes] = useState<string[]>([]);
|
||||
const [selectedMethods, setSelectedMethods] = useState<string[]>([]);
|
||||
const [selectedEndpoints, setSelectedEndpoints] = useState<string[]>([]);
|
||||
const [limit, setLimit] = useState<number>(100);
|
||||
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||
|
||||
// Load filters from localStorage on mount
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.selectedDate) setSelectedDate(parsed.selectedDate);
|
||||
if (parsed.selectedLevel) setSelectedLevel(parsed.selectedLevel);
|
||||
if (parsed.requestId) setRequestId(parsed.requestId);
|
||||
if (parsed.searchText) setSearchText(parsed.searchText);
|
||||
if (parsed.selectedStatusCodes)
|
||||
setSelectedStatusCodes(parsed.selectedStatusCodes);
|
||||
if (parsed.selectedMethods) setSelectedMethods(parsed.selectedMethods);
|
||||
if (parsed.selectedEndpoints)
|
||||
setSelectedEndpoints(parsed.selectedEndpoints);
|
||||
if (parsed.limit) setLimit(parsed.limit);
|
||||
} catch (e) {
|
||||
console.error('Failed to load filters from localStorage', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save filters to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
const filters = {
|
||||
selectedDate,
|
||||
selectedLevel,
|
||||
requestId,
|
||||
searchText,
|
||||
selectedStatusCodes,
|
||||
selectedMethods,
|
||||
selectedEndpoints,
|
||||
limit,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
|
||||
}, [
|
||||
selectedDate,
|
||||
selectedLevel,
|
||||
requestId,
|
||||
searchText,
|
||||
selectedStatusCodes,
|
||||
selectedMethods,
|
||||
selectedEndpoints,
|
||||
limit,
|
||||
]);
|
||||
|
||||
const {
|
||||
data: logsData,
|
||||
refetch: refetchLogs,
|
||||
@@ -42,6 +93,9 @@ export default function LogsPage() {
|
||||
selectedLevel,
|
||||
requestId,
|
||||
searchText,
|
||||
selectedStatusCodes,
|
||||
selectedMethods,
|
||||
selectedEndpoints,
|
||||
limit,
|
||||
],
|
||||
queryFn: () =>
|
||||
@@ -50,6 +104,16 @@ export default function LogsPage() {
|
||||
level: selectedLevel === 'all' ? undefined : selectedLevel,
|
||||
request_id: requestId || undefined,
|
||||
search: searchText || undefined,
|
||||
status_codes:
|
||||
selectedStatusCodes.length > 0
|
||||
? selectedStatusCodes.join(',')
|
||||
: undefined,
|
||||
methods:
|
||||
selectedMethods.length > 0 ? selectedMethods.join(',') : undefined,
|
||||
endpoints:
|
||||
selectedEndpoints.length > 0
|
||||
? selectedEndpoints.join(',')
|
||||
: undefined,
|
||||
limit: limit,
|
||||
}),
|
||||
refetchInterval: 30000,
|
||||
@@ -60,6 +124,9 @@ export default function LogsPage() {
|
||||
setSelectedLevel('all');
|
||||
setRequestId('');
|
||||
setSearchText('');
|
||||
setSelectedStatusCodes([]);
|
||||
setSelectedMethods([]);
|
||||
setSelectedEndpoints([]);
|
||||
setLimit(100);
|
||||
};
|
||||
|
||||
@@ -100,11 +167,17 @@ export default function LogsPage() {
|
||||
selectedLevel={selectedLevel}
|
||||
requestId={requestId}
|
||||
searchText={searchText}
|
||||
selectedStatusCodes={selectedStatusCodes}
|
||||
selectedMethods={selectedMethods}
|
||||
selectedEndpoints={selectedEndpoints}
|
||||
limit={limit}
|
||||
onDateChange={setSelectedDate}
|
||||
onLevelChange={setSelectedLevel}
|
||||
onRequestIdChange={setRequestId}
|
||||
onSearchTextChange={setSearchText}
|
||||
onStatusCodesChange={setSelectedStatusCodes}
|
||||
onMethodsChange={setSelectedMethods}
|
||||
onEndpointsChange={setSelectedEndpoints}
|
||||
onLimitChange={setLimit}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
@@ -122,13 +195,22 @@ export default function LogsPage() {
|
||||
{(selectedDate !== 'all' ||
|
||||
selectedLevel !== 'all' ||
|
||||
requestId ||
|
||||
searchText) && (
|
||||
searchText ||
|
||||
selectedStatusCodes.length > 0 ||
|
||||
selectedMethods.length > 0 ||
|
||||
selectedEndpoints.length > 0) && (
|
||||
<CardDescription className='text-xs sm:text-sm'>
|
||||
Showing logs
|
||||
{selectedDate !== 'all' && ` for ${selectedDate}`}
|
||||
{selectedLevel !== 'all' && ` with level ${selectedLevel}`}
|
||||
{requestId && ` with request ID ${requestId}`}
|
||||
{searchText && ` matching "${searchText}"`}
|
||||
{selectedStatusCodes.length > 0 &&
|
||||
` with status ${selectedStatusCodes.join(', ')}`}
|
||||
{selectedMethods.length > 0 &&
|
||||
` with method ${selectedMethods.join(', ')}`}
|
||||
{selectedEndpoints.length > 0 &&
|
||||
` with endpoint ${selectedEndpoints.join(', ')}`}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
@@ -17,6 +17,9 @@ export interface LogsResponse {
|
||||
level: string | null;
|
||||
request_id: string | null;
|
||||
search: string | null;
|
||||
status_codes: string | null;
|
||||
methods: string | null;
|
||||
endpoints: string | null;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
|
||||
+30
-18
@@ -204,47 +204,59 @@ export default function ModelsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{totalModels === 0 ? (
|
||||
<Alert>
|
||||
{totalModels === 0 && (
|
||||
<Alert className='mb-4'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
<div className='space-y-2'>
|
||||
<p className='font-medium'>
|
||||
No models found for this provider
|
||||
</p>
|
||||
<div className='text-sm space-y-1'>
|
||||
<p className='font-medium'>Common issues:</p>
|
||||
<ul className='list-disc list-inside space-y-1 ml-2'>
|
||||
<div className='space-y-1 text-sm'>
|
||||
<p className='font-medium'>
|
||||
Common issues:
|
||||
</p>
|
||||
<ul className='ml-2 list-inside list-disc space-y-1'>
|
||||
<li>
|
||||
<strong>API credentials:</strong> Check if the API key is correct and has the right permissions
|
||||
<strong>API credentials:</strong>{' '}
|
||||
Check if the API key is correct
|
||||
and has the right permissions
|
||||
</li>
|
||||
<li>
|
||||
<strong>Base URL:</strong> Verify the base URL is correct for your provider
|
||||
<strong>Base URL:</strong> Verify
|
||||
the base URL is correct for your
|
||||
provider
|
||||
</li>
|
||||
<li>
|
||||
<strong>Network access:</strong> Ensure the server can reach the provider's API endpoint
|
||||
<strong>Network access:</strong>{' '}
|
||||
Ensure the server can reach the
|
||||
provider's API endpoint
|
||||
</li>
|
||||
<li>
|
||||
<strong>Provider status:</strong> The upstream provider might be temporarily unavailable
|
||||
<strong>Provider status:</strong>{' '}
|
||||
The upstream provider might be
|
||||
temporarily unavailable
|
||||
</li>
|
||||
</ul>
|
||||
{groupData?.group_url && (
|
||||
<p className='mt-2 text-xs text-muted-foreground'>
|
||||
Current endpoint: <code className='bg-muted px-1 rounded'>{groupData.group_url}</code>
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
Current endpoint:{' '}
|
||||
<code className='bg-muted rounded px-1'>
|
||||
{groupData.group_url}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ModelSelector
|
||||
filterProvider={provider}
|
||||
groupData={groupData}
|
||||
showProviderActions={true}
|
||||
showDeleteAllButton={false}
|
||||
/>
|
||||
)}
|
||||
<ModelSelector
|
||||
filterProvider={provider}
|
||||
groupData={groupData}
|
||||
showProviderActions={true}
|
||||
showDeleteAllButton={false}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
|
||||
+49
-23
@@ -53,7 +53,7 @@ export default function DashboardPage() {
|
||||
window.removeEventListener('storage', syncAuthState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
const { data: btcUsdPrice } = useQuery({
|
||||
queryKey: ['btc-usd-price'],
|
||||
queryFn: fetchBtcUsdPrice,
|
||||
@@ -131,8 +131,13 @@ export default function DashboardPage() {
|
||||
<SiteHeader />
|
||||
<div className='container max-w-7xl px-4 py-8 md:px-6 lg:px-8'>
|
||||
<div className='mb-8'>
|
||||
<h1 className='text-3xl font-bold tracking-tight mb-6'>Dashboard</h1>
|
||||
<DashboardBalanceSummary displayUnit={displayUnit} usdPerSat={usdPerSat} />
|
||||
<h1 className='mb-6 text-3xl font-bold tracking-tight'>
|
||||
Dashboard
|
||||
</h1>
|
||||
<DashboardBalanceSummary
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between'>
|
||||
@@ -140,8 +145,9 @@ export default function DashboardPage() {
|
||||
<h2 className='text-xl font-semibold tracking-tight'>
|
||||
Usage Analytics
|
||||
</h2>
|
||||
<p className='text-muted-foreground text-sm mt-1'>
|
||||
Monitor requests, errors, and revenue over the last {timeRange} hours
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Monitor requests, errors, and revenue over the last {timeRange}{' '}
|
||||
hours
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-4'>
|
||||
@@ -176,27 +182,31 @@ export default function DashboardPage() {
|
||||
|
||||
<div className='space-y-6'>
|
||||
{summaryLoading ? (
|
||||
<div className='text-center py-8'>Loading summary...</div>
|
||||
<div className='py-8 text-center'>Loading summary...</div>
|
||||
) : summaryData ? (
|
||||
<UsageSummaryCards summary={summaryData} />
|
||||
) : null}
|
||||
|
||||
<div className='grid gap-6 lg:grid-cols-2'>
|
||||
{metricsLoading ? (
|
||||
<div className='text-center py-8 col-span-2'>
|
||||
<div className='col-span-2 py-8 text-center'>
|
||||
Loading metrics...
|
||||
</div>
|
||||
) : metricsData && metricsData.metrics.length > 0 ? (
|
||||
<>
|
||||
<div className="col-span-full">
|
||||
<div className='col-span-full'>
|
||||
<UsageMetricsChart
|
||||
data={metricsData.metrics.map((m) => ({
|
||||
...m,
|
||||
revenue_sats: m.revenue_msats / 1000,
|
||||
refunds_sats: m.refunds_msats / 1000,
|
||||
net_revenue_sats:
|
||||
(m.revenue_msats - m.refunds_msats) / 1000,
|
||||
})) as Array<Record<string, unknown> & { timestamp: string }>}
|
||||
data={
|
||||
metricsData.metrics.map((m) => ({
|
||||
...m,
|
||||
revenue_sats: m.revenue_msats / 1000,
|
||||
refunds_sats: m.refunds_msats / 1000,
|
||||
net_revenue_sats:
|
||||
(m.revenue_msats - m.refunds_msats) / 1000,
|
||||
})) as Array<
|
||||
Record<string, unknown> & { timestamp: string }
|
||||
>
|
||||
}
|
||||
title='Revenue Over Time (sats)'
|
||||
dataKeys={[
|
||||
{
|
||||
@@ -218,7 +228,11 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
<UsageMetricsChart
|
||||
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
|
||||
data={
|
||||
metricsData.metrics as Array<
|
||||
Record<string, unknown> & { timestamp: string }
|
||||
>
|
||||
}
|
||||
title='Request Volume'
|
||||
dataKeys={[
|
||||
{
|
||||
@@ -239,7 +253,11 @@ export default function DashboardPage() {
|
||||
]}
|
||||
/>
|
||||
<UsageMetricsChart
|
||||
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
|
||||
data={
|
||||
metricsData.metrics as Array<
|
||||
Record<string, unknown> & { timestamp: string }
|
||||
>
|
||||
}
|
||||
title='Error Tracking'
|
||||
dataKeys={[
|
||||
{
|
||||
@@ -260,7 +278,11 @@ export default function DashboardPage() {
|
||||
]}
|
||||
/>
|
||||
<UsageMetricsChart
|
||||
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
|
||||
data={
|
||||
metricsData.metrics as Array<
|
||||
Record<string, unknown> & { timestamp: string }
|
||||
>
|
||||
}
|
||||
title='Payment Activity'
|
||||
dataKeys={[
|
||||
{
|
||||
@@ -270,7 +292,7 @@ export default function DashboardPage() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<div className='space-y-6'>
|
||||
{summaryData && summaryData.unique_models.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -306,7 +328,9 @@ export default function DashboardPage() {
|
||||
key={type}
|
||||
className='flex items-center justify-between'
|
||||
>
|
||||
<span className='text-sm font-medium'>{type}</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{type}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{count}
|
||||
</span>
|
||||
@@ -335,16 +359,18 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{revenueByModelLoading ? (
|
||||
<div className='text-center py-8'>Loading revenue by model...</div>
|
||||
<div className='py-8 text-center'>
|
||||
Loading revenue by model...
|
||||
</div>
|
||||
) : revenueByModelData && revenueByModelData.models.length > 0 ? (
|
||||
<RevenueByModelTable
|
||||
<RevenueByModelTable
|
||||
models={revenueByModelData.models}
|
||||
totalRevenue={revenueByModelData.total_revenue_sats}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{errorLoading ? (
|
||||
<div className='text-center py-8'>Loading errors...</div>
|
||||
<div className='py-8 text-center'>Loading errors...</div>
|
||||
) : errorData ? (
|
||||
<ErrorDetailsTable errors={errorData.errors} />
|
||||
) : null}
|
||||
|
||||
+644
-136
@@ -18,7 +18,9 @@ import {
|
||||
UpstreamProvider,
|
||||
CreateUpstreamProvider,
|
||||
UpdateUpstreamProvider,
|
||||
AdminModel,
|
||||
} from '@/lib/api/services/admin';
|
||||
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -51,9 +53,336 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
function ProviderBalance({
|
||||
providerId,
|
||||
platformUrl,
|
||||
}: {
|
||||
providerId: number;
|
||||
platformUrl?: string | null;
|
||||
}) {
|
||||
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
|
||||
const [topupAmount, setTopupAmount] = useState('');
|
||||
const [topupError, setTopupError] = useState('');
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [invoiceData, setInvoiceData] = useState<{
|
||||
payment_request: string;
|
||||
invoice_id: string;
|
||||
} | null>(null);
|
||||
const [paymentStatus, setPaymentStatus] = useState<'pending' | 'paid' | null>(
|
||||
null
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: balanceData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['provider-balance', providerId],
|
||||
queryFn: () => AdminService.getProviderBalance(providerId),
|
||||
refetchInterval: 30000,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const { data: statusData } = useQuery({
|
||||
queryKey: ['topup-status', providerId, invoiceData?.invoice_id],
|
||||
queryFn: () =>
|
||||
AdminService.checkTopupStatus(providerId, invoiceData!.invoice_id),
|
||||
enabled: !!invoiceData && paymentStatus === 'pending',
|
||||
refetchInterval: 2000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (statusData?.paid === true) {
|
||||
setPaymentStatus('paid');
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-balance', providerId],
|
||||
});
|
||||
toast.success('Payment received!', {
|
||||
description: 'Your balance has been updated.',
|
||||
});
|
||||
}
|
||||
}, [statusData, queryClient, providerId]);
|
||||
|
||||
const topupMutation = useMutation({
|
||||
mutationFn: async (amount: number) => {
|
||||
console.log('Calling top-up API with:', { providerId, amount });
|
||||
try {
|
||||
const result = await AdminService.initiateProviderTopup(
|
||||
providerId,
|
||||
amount
|
||||
);
|
||||
console.log('API returned:', result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('API call failed:', err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
console.log('Top-up response:', data);
|
||||
console.log('Type of data:', typeof data);
|
||||
console.log('Keys in data:', Object.keys(data || {}));
|
||||
|
||||
if (data?.topup_data?.payment_request && data?.topup_data?.invoice_id) {
|
||||
setInvoiceData({
|
||||
payment_request: data.topup_data.payment_request as string,
|
||||
invoice_id: data.topup_data.invoice_id as string,
|
||||
});
|
||||
setPaymentStatus('pending');
|
||||
} else {
|
||||
console.error('Missing invoice data:', data);
|
||||
console.error('topup_data:', data?.topup_data);
|
||||
toast.error('No invoice returned from provider');
|
||||
setIsTopupDialogOpen(false);
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('Top-up mutation error:', error);
|
||||
toast.error(`Failed to initiate top-up: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleTopup = () => {
|
||||
// If no dialog open logic (which depends on API implementation),
|
||||
// we check if we should redirect or open dialog based on available info
|
||||
// But since this function is called inside the dialog, we might want to change
|
||||
// how the "Top Up" button behaves instead.
|
||||
const amount = parseFloat(topupAmount);
|
||||
|
||||
if (isNaN(amount)) {
|
||||
setTopupError('Please enter a valid amount');
|
||||
return;
|
||||
}
|
||||
|
||||
if (amount < 1 || amount > 500) {
|
||||
setTopupError('Amount must be between $1 and $500');
|
||||
return;
|
||||
}
|
||||
|
||||
topupMutation.mutate(amount);
|
||||
};
|
||||
|
||||
const handleTopUpClick = () => {
|
||||
// Check if the provider supports direct topup (currently only PPQ.AI effectively)
|
||||
// We can infer this if it's NOT OpenRouter or OpenAI, or strictly checking provider capability
|
||||
// For now, we'll try to initiate topup for anyone, but if we know it fails (or isn't implemented),
|
||||
// we should redirect.
|
||||
// However, the prompt asks to redirect if topup is not implemented.
|
||||
// The backend throws 500/400 if not implemented.
|
||||
// A better approach is to check if we have a platform URL and maybe redirect there
|
||||
// if we know it's not supported.
|
||||
|
||||
// BUT, we don't know for sure if it's supported without checking metadata or trying.
|
||||
// Let's rely on the "can_topup" metadata if available, but currently we only have "can_show_balance".
|
||||
|
||||
// Simple heuristic: If platformUrl exists and we suspect no direct topup, redirect?
|
||||
// Actually, let's try to open the dialog, but if it's OpenRouter/OpenAI, maybe we just redirect?
|
||||
// The user specifically mentioned "like in openrouter".
|
||||
|
||||
if (
|
||||
platformUrl &&
|
||||
(platformUrl.includes('openrouter.ai') ||
|
||||
platformUrl.includes('openai.com'))
|
||||
) {
|
||||
window.open(platformUrl, '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTopupDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setIsTopupDialogOpen(false);
|
||||
setTopupAmount('');
|
||||
setTopupError('');
|
||||
setInvoiceData(null);
|
||||
setPaymentStatus(null);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className='h-9 w-24' />;
|
||||
}
|
||||
|
||||
if (
|
||||
error ||
|
||||
!balanceData?.ok ||
|
||||
balanceData.balance_data === undefined ||
|
||||
balanceData.balance_data === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const balance = balanceData.balance_data;
|
||||
let displayValue = 'N/A';
|
||||
|
||||
if (typeof balance === 'number') {
|
||||
displayValue = `$${balance.toFixed(2)}`;
|
||||
} else if (balance && typeof balance === 'object') {
|
||||
// Legacy support for object response
|
||||
const b = balance as Record<string, unknown>;
|
||||
if (typeof b.balance === 'number') {
|
||||
displayValue = `$${b.balance.toFixed(2)}`;
|
||||
} else if (typeof b.balance === 'string') {
|
||||
displayValue = b.balance;
|
||||
} else if (b.amount !== undefined) {
|
||||
displayValue = `$${Number(b.amount).toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleTopUpClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className='w-full font-mono sm:w-auto'
|
||||
>
|
||||
{isHovered ? 'Top Up' : displayValue}
|
||||
</Button>
|
||||
|
||||
<Dialog open={isTopupDialogOpen} onOpenChange={handleCloseDialog}>
|
||||
<DialogContent className='sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{paymentStatus === 'paid'
|
||||
? 'Payment Confirmed!'
|
||||
: 'Top Up Balance'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{paymentStatus === 'paid'
|
||||
? 'Your account balance has been updated.'
|
||||
: invoiceData
|
||||
? 'Scan the QR code or copy the Lightning invoice to pay.'
|
||||
: 'Enter the amount you want to add to your account balance.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{paymentStatus === 'paid' ? (
|
||||
<div className='flex flex-col items-center gap-4 py-6'>
|
||||
<div className='rounded-full bg-green-100 p-3 dark:bg-green-900'>
|
||||
<svg
|
||||
className='h-12 w-12 text-green-600 dark:text-green-400'
|
||||
fill='none'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
>
|
||||
<path d='M5 13l4 4L19 7'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p className='text-center font-semibold'>Top-up successful!</p>
|
||||
</div>
|
||||
) : invoiceData ? (
|
||||
<div className='flex flex-col items-center gap-4 py-4'>
|
||||
<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
|
||||
)}`}
|
||||
alt='Lightning Invoice QR Code'
|
||||
className='h-64 w-64'
|
||||
/>
|
||||
</div>
|
||||
<div className='w-full space-y-2'>
|
||||
<Label htmlFor='invoice'>Lightning Invoice</Label>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='invoice'
|
||||
value={invoiceData.payment_request}
|
||||
readOnly
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
invoiceData.payment_request
|
||||
);
|
||||
toast.success('Invoice copied to clipboard!');
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{paymentStatus === 'pending' && (
|
||||
<p className='text-muted-foreground text-center text-sm'>
|
||||
Waiting for payment...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid gap-4 py-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='topup_amount'>Amount (USD)</Label>
|
||||
<Input
|
||||
id='topup_amount'
|
||||
type='number'
|
||||
placeholder='Enter amount (1-500)'
|
||||
value={topupAmount}
|
||||
onChange={(e) => {
|
||||
setTopupAmount(e.target.value);
|
||||
setTopupError('');
|
||||
}}
|
||||
min='1'
|
||||
max='500'
|
||||
step='0.01'
|
||||
/>
|
||||
{topupError && (
|
||||
<p className='text-sm text-red-600 dark:text-red-400'>
|
||||
{topupError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{paymentStatus === 'paid' ? (
|
||||
<Button onClick={handleCloseDialog} className='w-full'>
|
||||
Done
|
||||
</Button>
|
||||
) : invoiceData ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleCloseDialog}
|
||||
className='w-full'
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant='outline' onClick={handleCloseDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleTopup}
|
||||
disabled={topupMutation.isPending || !topupAmount}
|
||||
>
|
||||
{topupMutation.isPending
|
||||
? 'Processing...'
|
||||
: 'Generate Invoice'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProvidersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [editingProvider, setEditingProvider] =
|
||||
@@ -64,6 +393,18 @@ export default function ProvidersPage() {
|
||||
new Set()
|
||||
);
|
||||
const [viewingModels, setViewingModels] = useState<number | null>(null);
|
||||
const [isCreatingAccount, setIsCreatingAccount] = useState(false);
|
||||
const [modelDialogState, setModelDialogState] = useState<{
|
||||
isOpen: boolean;
|
||||
providerId: number | null;
|
||||
mode: 'create' | 'edit' | 'override';
|
||||
initialData?: AdminModel | null;
|
||||
}>({
|
||||
isOpen: false,
|
||||
providerId: null,
|
||||
mode: 'create',
|
||||
initialData: null,
|
||||
});
|
||||
|
||||
const [formData, setFormData] = useState<CreateUpstreamProvider>({
|
||||
provider_type: 'openrouter',
|
||||
@@ -71,8 +412,13 @@ export default function ProvidersPage() {
|
||||
api_key: '',
|
||||
api_version: null,
|
||||
enabled: true,
|
||||
provider_fee: 1.06,
|
||||
});
|
||||
|
||||
const getProviderFeePlaceholder = (type: string) => {
|
||||
return type === 'openrouter' ? 'Default: 1.06 (6%)' : 'Default: 1.01 (1%)';
|
||||
};
|
||||
|
||||
const { data: providerTypes = [] } = useQuery({
|
||||
queryKey: ['provider-types'],
|
||||
queryFn: () => AdminService.getProviderTypes(),
|
||||
@@ -138,6 +484,32 @@ export default function ProvidersPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateAccount = async () => {
|
||||
setIsCreatingAccount(true);
|
||||
try {
|
||||
const response = await AdminService.createProviderAccountByType(
|
||||
formData.provider_type
|
||||
);
|
||||
if (response.ok && response.account_data.api_key) {
|
||||
setFormData({
|
||||
...formData,
|
||||
api_key: String(response.account_data.api_key),
|
||||
});
|
||||
toast.success(
|
||||
'Account created successfully! API key has been filled in.'
|
||||
);
|
||||
} else {
|
||||
toast.success('Account created, but no API key returned.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
toast.error(`Failed to create account: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsCreatingAccount(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
provider_type: 'openrouter',
|
||||
@@ -149,7 +521,11 @@ export default function ProvidersPage() {
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
createMutation.mutate(formData);
|
||||
const data = { ...formData };
|
||||
if (data.provider_fee === undefined || data.provider_fee === null) {
|
||||
data.provider_fee = data.provider_type === 'openrouter' ? 1.06 : 1.01;
|
||||
}
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
const handleEdit = (provider: UpstreamProvider) => {
|
||||
@@ -160,6 +536,7 @@ export default function ProvidersPage() {
|
||||
api_key: '',
|
||||
api_version: provider.api_version || null,
|
||||
enabled: provider.enabled,
|
||||
provider_fee: provider.provider_fee,
|
||||
});
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
@@ -171,6 +548,7 @@ export default function ProvidersPage() {
|
||||
base_url: formData.base_url,
|
||||
api_version: formData.api_version,
|
||||
enabled: formData.enabled,
|
||||
provider_fee: formData.provider_fee,
|
||||
};
|
||||
if (formData.api_key) {
|
||||
updateData.api_key = formData.api_key;
|
||||
@@ -199,6 +577,16 @@ export default function ProvidersPage() {
|
||||
return providerType?.platform_url || null;
|
||||
};
|
||||
|
||||
const canCreateAccount = (type: string) => {
|
||||
const providerType = providerTypes.find((pt) => pt.id === type);
|
||||
return providerType?.can_create_account || false;
|
||||
};
|
||||
|
||||
const canShowBalance = (type: string) => {
|
||||
const providerType = providerTypes.find((pt) => pt.id === type);
|
||||
return providerType?.can_show_balance || false;
|
||||
};
|
||||
|
||||
const toggleProviderExpansion = (providerId: number) => {
|
||||
const newExpanded = new Set(expandedProviders);
|
||||
if (newExpanded.has(providerId)) {
|
||||
@@ -214,6 +602,33 @@ export default function ProvidersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddModel = (providerId: number) => {
|
||||
setModelDialogState({
|
||||
isOpen: true,
|
||||
providerId,
|
||||
mode: 'create',
|
||||
initialData: null,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditModel = (providerId: number, model: AdminModel) => {
|
||||
setModelDialogState({
|
||||
isOpen: true,
|
||||
providerId,
|
||||
mode: 'edit',
|
||||
initialData: model,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOverrideModel = (providerId: number, model: AdminModel) => {
|
||||
setModelDialogState({
|
||||
isOpen: true,
|
||||
providerId,
|
||||
mode: 'override',
|
||||
initialData: model,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
@@ -253,11 +668,12 @@ export default function ProvidersPage() {
|
||||
<Select
|
||||
value={formData.provider_type}
|
||||
onValueChange={(value) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
provider_type: value,
|
||||
base_url: getDefaultBaseUrl(value),
|
||||
});
|
||||
provider_fee: value === 'openrouter' ? 1.06 : 1.01,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -292,15 +708,30 @@ export default function ProvidersPage() {
|
||||
<div className='grid gap-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='api_key'>API Key</Label>
|
||||
{getPlatformUrl(formData.provider_type) && (
|
||||
<a
|
||||
href={getPlatformUrl(formData.provider_type)!}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300'
|
||||
{canCreateAccount(formData.provider_type) ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleCreateAccount}
|
||||
disabled={isCreatingAccount}
|
||||
className='h-6 text-xs'
|
||||
>
|
||||
Get Your API Key Here →
|
||||
</a>
|
||||
{isCreatingAccount
|
||||
? 'Creating...'
|
||||
: 'Create Account'}
|
||||
</Button>
|
||||
) : (
|
||||
getPlatformUrl(formData.provider_type) && (
|
||||
<a
|
||||
href={getPlatformUrl(formData.provider_type)!}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300'
|
||||
>
|
||||
Get Your API Key Here →
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
@@ -339,6 +770,32 @@ export default function ProvidersPage() {
|
||||
/>
|
||||
<Label htmlFor='enabled'>Enabled</Label>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='provider_fee'>
|
||||
Provider Fee (Multiplier)
|
||||
</Label>
|
||||
<Input
|
||||
id='provider_fee'
|
||||
type='number'
|
||||
step='0.001'
|
||||
min='1.0'
|
||||
value={formData.provider_fee || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
provider_fee: e.target.value
|
||||
? parseFloat(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder={getProviderFeePlaceholder(
|
||||
formData.provider_type
|
||||
)}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
1.01 means +1% e.g. currency exchange, card fees, etc.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -410,7 +867,16 @@ export default function ProvidersPage() {
|
||||
{provider.base_url}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className='grid grid-cols-3 gap-2 sm:flex sm:flex-nowrap'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{canShowBalance(provider.provider_type) &&
|
||||
provider.api_key && (
|
||||
<ProviderBalance
|
||||
providerId={provider.id}
|
||||
platformUrl={getPlatformUrl(
|
||||
provider.provider_type
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
@@ -469,13 +935,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'>
|
||||
<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'>
|
||||
Custom models override or extend the
|
||||
provider'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 models configured. Add custom models to
|
||||
use this provider.
|
||||
No custom models configured
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
@@ -506,148 +1030,88 @@ export default function ProvidersPage() {
|
||||
{model.description || model.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs whitespace-nowrap'>
|
||||
{model.context_length?.toLocaleString()}{' '}
|
||||
tokens
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='text-muted-foreground text-xs whitespace-nowrap'>
|
||||
{model.context_length?.toLocaleString()}{' '}
|
||||
tokens
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={() =>
|
||||
handleEditModel(
|
||||
provider.id,
|
||||
model
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pencil 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'
|
||||
>
|
||||
{providerModels.db_models.length > 0 && (
|
||||
{providerModels.remote_models.length > 0 ? (
|
||||
<>
|
||||
<div className='text-muted-foreground mb-3 text-sm'>
|
||||
Custom models override or extend the
|
||||
Models automatically discovered from the
|
||||
provider's catalog.
|
||||
</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'>
|
||||
{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 ||
|
||||
model.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs whitespace-nowrap'>
|
||||
{model.context_length?.toLocaleString()}{' '}
|
||||
tokens
|
||||
<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>
|
||||
)}
|
||||
</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'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='text-muted-foreground text-xs whitespace-nowrap'>
|
||||
{model.context_length?.toLocaleString()}{' '}
|
||||
tokens
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className='text-muted-foreground py-4 text-center text-sm'>
|
||||
No provided models available
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
@@ -674,11 +1138,12 @@ export default function ProvidersPage() {
|
||||
<Select
|
||||
value={formData.provider_type}
|
||||
onValueChange={(value) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
provider_type: value,
|
||||
base_url: getDefaultBaseUrl(value),
|
||||
});
|
||||
provider_fee: value === 'openrouter' ? 1.06 : 1.01,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -762,6 +1227,32 @@ export default function ProvidersPage() {
|
||||
/>
|
||||
<Label htmlFor='edit_enabled'>Enabled</Label>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='edit_provider_fee'>
|
||||
Provider Fee (Multiplier)
|
||||
</Label>
|
||||
<Input
|
||||
id='edit_provider_fee'
|
||||
type='number'
|
||||
step='0.001'
|
||||
min='1.0'
|
||||
value={formData.provider_fee || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
provider_fee: e.target.value
|
||||
? parseFloat(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder={getProviderFeePlaceholder(
|
||||
formData.provider_type
|
||||
)}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
1.01 means +1% e.g. currency exchange, card fees, etc.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -779,6 +1270,23 @@ export default function ProvidersPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{modelDialogState.providerId && (
|
||||
<AddProviderModelDialog
|
||||
providerId={modelDialogState.providerId}
|
||||
isOpen={modelDialogState.isOpen}
|
||||
onClose={() =>
|
||||
setModelDialogState((prev) => ({ ...prev, isOpen: false }))
|
||||
}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-models', modelDialogState.providerId],
|
||||
});
|
||||
}}
|
||||
initialData={modelDialogState.initialData}
|
||||
mode={modelDialogState.mode}
|
||||
/>
|
||||
)}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,920 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Loader2, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
|
||||
|
||||
const listFromString = (value: string): string[] =>
|
||||
value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
|
||||
const listToString = (value: string[] | undefined | null): string =>
|
||||
value && value.length > 0 ? value.join(', ') : '';
|
||||
|
||||
const FormSchema = z.object({
|
||||
id: z.string().min(1, 'Model ID is required'),
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
description: z.string().default(''),
|
||||
context_length: z.coerce.number().min(0).default(8192),
|
||||
modality: z.string().min(1, 'Modality is required'),
|
||||
input_modalities_raw: z.string().default(''),
|
||||
output_modalities_raw: z.string().default(''),
|
||||
tokenizer: z.string().default(''),
|
||||
instruct_type: z.string().default(''),
|
||||
canonical_slug: z.string().default(''),
|
||||
alias_ids_raw: z.string().default(''),
|
||||
upstream_provider_id: z.string().default(''),
|
||||
input_cost: z.coerce.number().min(0).default(0),
|
||||
output_cost: z.coerce.number().min(0).default(0),
|
||||
request_cost: z.coerce.number().min(0).default(0),
|
||||
image_cost: z.coerce.number().min(0).default(0),
|
||||
web_search_cost: z.coerce.number().min(0).default(0),
|
||||
internal_reasoning_cost: z.coerce.number().min(0).default(0),
|
||||
max_prompt_cost: z.coerce.number().min(0).default(0),
|
||||
max_completion_cost: z.coerce.number().min(0).default(0),
|
||||
max_cost: z.coerce.number().min(0).default(0),
|
||||
per_request_limits_raw: z.string().default(''),
|
||||
top_provider_context_length: z.coerce.number().min(0).optional(),
|
||||
top_provider_max_completion_tokens: z.coerce.number().min(0).optional(),
|
||||
top_provider_is_moderated: z.boolean().default(false),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
type FormData = z.output<typeof FormSchema>;
|
||||
|
||||
export interface AddProviderModelDialogProps {
|
||||
providerId: number;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
initialData?: AdminModel | null;
|
||||
mode?: 'create' | 'edit' | 'override';
|
||||
}
|
||||
|
||||
export function AddProviderModelDialog({
|
||||
providerId,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
initialData,
|
||||
mode = 'create',
|
||||
}: AddProviderModelDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isPresetOpen, setIsPresetOpen] = useState(false);
|
||||
const [selectedPresetLabel, setSelectedPresetLabel] =
|
||||
useState('Select a preset');
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(FormSchema) as never,
|
||||
defaultValues: {
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
context_length: 8192,
|
||||
modality: 'text',
|
||||
input_modalities_raw: 'text',
|
||||
output_modalities_raw: 'text',
|
||||
tokenizer: '',
|
||||
instruct_type: '',
|
||||
canonical_slug: '',
|
||||
alias_ids_raw: '',
|
||||
upstream_provider_id: '',
|
||||
input_cost: 0,
|
||||
output_cost: 0,
|
||||
request_cost: 0,
|
||||
image_cost: 0,
|
||||
web_search_cost: 0,
|
||||
internal_reasoning_cost: 0,
|
||||
max_prompt_cost: 0,
|
||||
max_completion_cost: 0,
|
||||
max_cost: 0,
|
||||
per_request_limits_raw: '',
|
||||
top_provider_context_length: undefined,
|
||||
top_provider_max_completion_tokens: undefined,
|
||||
top_provider_is_moderated: false,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const isOverride = useMemo(() => mode === 'override', [mode]);
|
||||
const isEdit = useMemo(() => mode === 'edit', [mode]);
|
||||
const { data: presets = [], isLoading: isLoadingPresets } = useQuery({
|
||||
queryKey: ['openrouter-presets'],
|
||||
queryFn: () => AdminService.getOpenRouterPresets(),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
const architecture = initialData.architecture as Record<string, unknown>;
|
||||
const pricing = initialData.pricing as Record<string, number>;
|
||||
const topProvider = initialData.top_provider as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
form.reset({
|
||||
id: initialData.id,
|
||||
name: initialData.name,
|
||||
description: initialData.description,
|
||||
context_length: initialData.context_length,
|
||||
modality:
|
||||
typeof architecture?.modality === 'string'
|
||||
? architecture.modality
|
||||
: 'text',
|
||||
input_modalities_raw: listToString(
|
||||
(architecture?.input_modalities as string[]) || []
|
||||
),
|
||||
output_modalities_raw: listToString(
|
||||
(architecture?.output_modalities as string[]) || []
|
||||
),
|
||||
tokenizer:
|
||||
typeof architecture?.tokenizer === 'string'
|
||||
? architecture.tokenizer
|
||||
: '',
|
||||
instruct_type:
|
||||
typeof architecture?.instruct_type === 'string'
|
||||
? architecture.instruct_type
|
||||
: '',
|
||||
canonical_slug: initialData.canonical_slug || '',
|
||||
alias_ids_raw: listToString(initialData.alias_ids),
|
||||
upstream_provider_id:
|
||||
typeof initialData.upstream_provider_id === 'string'
|
||||
? initialData.upstream_provider_id
|
||||
: initialData.upstream_provider_id?.toString() || '',
|
||||
input_cost: pricing?.prompt ?? 0,
|
||||
output_cost: pricing?.completion ?? 0,
|
||||
request_cost: pricing?.request ?? 0,
|
||||
image_cost: pricing?.image ?? 0,
|
||||
web_search_cost: pricing?.web_search ?? 0,
|
||||
internal_reasoning_cost: pricing?.internal_reasoning ?? 0,
|
||||
max_prompt_cost: pricing?.max_prompt_cost ?? 0,
|
||||
max_completion_cost: pricing?.max_completion_cost ?? 0,
|
||||
max_cost: pricing?.max_cost ?? 0,
|
||||
per_request_limits_raw: initialData.per_request_limits
|
||||
? JSON.stringify(initialData.per_request_limits, null, 2)
|
||||
: '',
|
||||
top_provider_context_length:
|
||||
typeof topProvider?.context_length === 'number'
|
||||
? topProvider.context_length
|
||||
: undefined,
|
||||
top_provider_max_completion_tokens:
|
||||
typeof topProvider?.max_completion_tokens === 'number'
|
||||
? topProvider.max_completion_tokens
|
||||
: undefined,
|
||||
top_provider_is_moderated:
|
||||
typeof topProvider?.is_moderated === 'boolean'
|
||||
? topProvider.is_moderated
|
||||
: false,
|
||||
enabled: initialData.enabled,
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
context_length: 8192,
|
||||
modality: 'text',
|
||||
input_modalities_raw: 'text',
|
||||
output_modalities_raw: 'text',
|
||||
tokenizer: '',
|
||||
instruct_type: '',
|
||||
canonical_slug: '',
|
||||
alias_ids_raw: '',
|
||||
upstream_provider_id: '',
|
||||
input_cost: 0,
|
||||
output_cost: 0,
|
||||
request_cost: 0,
|
||||
image_cost: 0,
|
||||
web_search_cost: 0,
|
||||
internal_reasoning_cost: 0,
|
||||
max_prompt_cost: 0,
|
||||
max_completion_cost: 0,
|
||||
max_cost: 0,
|
||||
per_request_limits_raw: '',
|
||||
top_provider_context_length: undefined,
|
||||
top_provider_max_completion_tokens: undefined,
|
||||
top_provider_is_moderated: false,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}, [initialData, form, isOpen]);
|
||||
|
||||
const applyModelToForm = (model: AdminModel) => {
|
||||
setSelectedPresetLabel(`${model.id} — ${model.name}`);
|
||||
const architecture = model.architecture as Record<string, unknown>;
|
||||
const pricing = model.pricing as Record<string, number>;
|
||||
const topProvider = model.top_provider as Record<string, unknown> | null;
|
||||
|
||||
if (!isOverride) {
|
||||
form.setValue('id', model.id);
|
||||
}
|
||||
form.setValue('name', model.name);
|
||||
form.setValue('description', model.description || '');
|
||||
form.setValue('context_length', model.context_length);
|
||||
form.setValue(
|
||||
'modality',
|
||||
typeof architecture?.modality === 'string'
|
||||
? architecture.modality
|
||||
: 'text'
|
||||
);
|
||||
form.setValue(
|
||||
'input_modalities_raw',
|
||||
listToString((architecture?.input_modalities as string[]) || [])
|
||||
);
|
||||
form.setValue(
|
||||
'output_modalities_raw',
|
||||
listToString((architecture?.output_modalities as string[]) || [])
|
||||
);
|
||||
form.setValue(
|
||||
'tokenizer',
|
||||
typeof architecture?.tokenizer === 'string' ? architecture.tokenizer : ''
|
||||
);
|
||||
form.setValue(
|
||||
'instruct_type',
|
||||
typeof architecture?.instruct_type === 'string'
|
||||
? architecture.instruct_type
|
||||
: ''
|
||||
);
|
||||
form.setValue('canonical_slug', model.canonical_slug || '');
|
||||
form.setValue('alias_ids_raw', listToString(model.alias_ids));
|
||||
form.setValue(
|
||||
'upstream_provider_id',
|
||||
typeof model.upstream_provider_id === 'string'
|
||||
? model.upstream_provider_id
|
||||
: model.upstream_provider_id?.toString() || ''
|
||||
);
|
||||
form.setValue('input_cost', pricing?.prompt ?? 0);
|
||||
form.setValue('output_cost', pricing?.completion ?? 0);
|
||||
form.setValue('request_cost', pricing?.request ?? 0);
|
||||
form.setValue('image_cost', pricing?.image ?? 0);
|
||||
form.setValue('web_search_cost', pricing?.web_search ?? 0);
|
||||
form.setValue('internal_reasoning_cost', pricing?.internal_reasoning ?? 0);
|
||||
form.setValue('max_prompt_cost', pricing?.max_prompt_cost ?? 0);
|
||||
form.setValue('max_completion_cost', pricing?.max_completion_cost ?? 0);
|
||||
form.setValue('max_cost', pricing?.max_cost ?? 0);
|
||||
form.setValue(
|
||||
'per_request_limits_raw',
|
||||
model.per_request_limits
|
||||
? JSON.stringify(model.per_request_limits, null, 2)
|
||||
: ''
|
||||
);
|
||||
form.setValue(
|
||||
'top_provider_context_length',
|
||||
typeof topProvider?.context_length === 'number'
|
||||
? topProvider.context_length
|
||||
: undefined
|
||||
);
|
||||
form.setValue(
|
||||
'top_provider_max_completion_tokens',
|
||||
typeof topProvider?.max_completion_tokens === 'number'
|
||||
? topProvider.max_completion_tokens
|
||||
: undefined
|
||||
);
|
||||
form.setValue(
|
||||
'top_provider_is_moderated',
|
||||
typeof topProvider?.is_moderated === 'boolean'
|
||||
? topProvider.is_moderated
|
||||
: false
|
||||
);
|
||||
form.setValue('enabled', model.enabled);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let perRequestLimits: Record<string, unknown> | null = null;
|
||||
if (
|
||||
data.per_request_limits_raw &&
|
||||
data.per_request_limits_raw.trim().length
|
||||
) {
|
||||
try {
|
||||
perRequestLimits = JSON.parse(data.per_request_limits_raw);
|
||||
} catch {
|
||||
toast.error('Per-request limits must be valid JSON');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const adminModel: AdminModel = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
description: data.description || '',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
context_length: data.context_length,
|
||||
architecture: {
|
||||
modality: data.modality,
|
||||
input_modalities: listFromString(
|
||||
data.input_modalities_raw || data.modality
|
||||
),
|
||||
output_modalities: listFromString(
|
||||
data.output_modalities_raw || data.modality
|
||||
),
|
||||
tokenizer: data.tokenizer || '',
|
||||
instruct_type: data.instruct_type?.trim() || null,
|
||||
},
|
||||
pricing: {
|
||||
prompt: data.input_cost,
|
||||
completion: data.output_cost,
|
||||
request: data.request_cost,
|
||||
image: data.image_cost,
|
||||
web_search: data.web_search_cost,
|
||||
internal_reasoning: data.internal_reasoning_cost,
|
||||
max_prompt_cost: data.max_prompt_cost,
|
||||
max_completion_cost: data.max_completion_cost,
|
||||
max_cost: data.max_cost,
|
||||
},
|
||||
per_request_limits: perRequestLimits,
|
||||
top_provider:
|
||||
data.top_provider_context_length ||
|
||||
data.top_provider_max_completion_tokens ||
|
||||
data.top_provider_is_moderated
|
||||
? {
|
||||
context_length: data.top_provider_context_length ?? null,
|
||||
max_completion_tokens:
|
||||
data.top_provider_max_completion_tokens ?? null,
|
||||
is_moderated: data.top_provider_is_moderated,
|
||||
}
|
||||
: null,
|
||||
upstream_provider_id: data.upstream_provider_id?.trim().length
|
||||
? data.upstream_provider_id.trim()
|
||||
: providerId,
|
||||
canonical_slug: data.canonical_slug?.trim() || null,
|
||||
alias_ids: listFromString(data.alias_ids_raw || ''),
|
||||
enabled: data.enabled,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
await AdminService.updateProviderModel(providerId, data.id, adminModel);
|
||||
toast.success('Model updated successfully');
|
||||
} else {
|
||||
await AdminService.createProviderModel(providerId, adminModel);
|
||||
toast.success(
|
||||
isOverride ? 'Model override created' : 'Model created successfully'
|
||||
);
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Unknown error saving model';
|
||||
toast.error(`Failed to save model: ${message}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const title = isEdit
|
||||
? 'Edit Model'
|
||||
: isOverride
|
||||
? 'Override Model'
|
||||
: 'Add Custom Model';
|
||||
const description = isOverride
|
||||
? 'Create a custom override for this upstream model'
|
||||
: 'Add a new model configuration for this provider';
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[720px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<Plus className='h-4 w-4' />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!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'>
|
||||
<Popover open={isPresetOpen} onOpenChange={setIsPresetOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={isPresetOpen}
|
||||
className='w-full justify-between overflow-hidden text-left text-sm'
|
||||
>
|
||||
<span className='truncate'>
|
||||
{isLoadingPresets
|
||||
? 'Loading presets...'
|
||||
: selectedPresetLabel}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='w-80 max-w-sm p-0'
|
||||
align='start'
|
||||
sideOffset={4}
|
||||
collisionPadding={12}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Command shouldFilter={true}>
|
||||
<CommandInput placeholder='Search presets...' />
|
||||
<CommandList
|
||||
className='max-h-64 overflow-y-auto overscroll-contain'
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isLoadingPresets ? (
|
||||
<CommandEmpty>Loading presets...</CommandEmpty>
|
||||
) : presets.length === 0 ? (
|
||||
<CommandEmpty>No presets available.</CommandEmpty>
|
||||
) : (
|
||||
<CommandGroup heading='Presets'>
|
||||
{presets.map((preset) => (
|
||||
<CommandItem
|
||||
key={preset.id}
|
||||
value={preset.id}
|
||||
onSelect={() => {
|
||||
applyModelToForm(preset);
|
||||
setIsPresetOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className='flex flex-col text-sm'>
|
||||
<span className='font-medium'>{preset.id}</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{preset.name}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-1 text-xs'>
|
||||
{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>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Model ID *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='e.g., gpt-5.1'
|
||||
{...field}
|
||||
disabled={isOverride || isEdit}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Unique identifier for the model
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Display Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='e.g., GPT-5.1' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder='Brief description...'
|
||||
{...field}
|
||||
rows={2}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='modality'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Modality *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='text, text+image->text, image->text, etc.'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Composite modality label (e.g., text+image->text)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='context_length'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Context Length</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='input_modalities_raw'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Input Modalities</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='text, image, file' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Comma-separated list</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='output_modalities_raw'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Output Modalities</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='text, image' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Comma-separated list</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='tokenizer'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tokenizer</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='e.g., GPT, tiktoken' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='instruct_type'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Instruct Type</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='e.g., chat, completion' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='canonical_slug'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Canonical Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='google/gemini-3-pro-preview-20251117'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='alias_ids_raw'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Alias IDs</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='alias-1, alias-2' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Comma-separated list</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='upstream_provider_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Upstream Provider ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='gemini' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Defaults to current provider if left blank
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='per_request_limits_raw'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Per-request Limits (JSON)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder='{"requests_per_min": 60}'
|
||||
{...field}
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
JSON object; leave empty for none
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/20 space-y-4 rounded-md border p-4'>
|
||||
<h4 className='text-sm font-medium'>
|
||||
Pricing (USD per 1M tokens)
|
||||
</h4>
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='input_cost'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Input Cost</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' step='0.01' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='output_cost'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Output Cost</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' step='0.01' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='request_cost'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Per Request Cost</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' step='0.0001' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='image_cost'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Image Cost (per image)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' step='0.0001' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='web_search_cost'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Web Search Cost</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' step='0.0001' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='internal_reasoning_cost'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Internal Reasoning Cost</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' step='0.0001' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/20 space-y-4 rounded-md border p-4'>
|
||||
<h4 className='text-sm font-medium'>Top Provider (optional)</h4>
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-3'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='top_provider_context_length'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Context Length</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='top_provider_max_completion_tokens'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max Completion Tokens</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='top_provider_is_moderated'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
Is Moderated
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Whether provider enforces moderation
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>Enabled</FormLabel>
|
||||
<FormDescription>Enable this model for use</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' disabled={isSubmitting}>
|
||||
{isSubmitting && (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
)}
|
||||
{isEdit
|
||||
? 'Save Changes'
|
||||
: isOverride
|
||||
? 'Create Override'
|
||||
: 'Create Model'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,10 @@ export function ModelSearchFilter({
|
||||
model.name.toLowerCase().includes(query) ||
|
||||
model.full_name.toLowerCase().includes(query) ||
|
||||
model.provider.toLowerCase().includes(query) ||
|
||||
(model.alias_ids &&
|
||||
model.alias_ids.some((alias) =>
|
||||
alias.toLowerCase().includes(query)
|
||||
)) ||
|
||||
(model.description &&
|
||||
model.description.toLowerCase().includes(query)) ||
|
||||
model.modelType.toLowerCase().includes(query)
|
||||
|
||||
+153
-70
@@ -2,15 +2,14 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { type Model, type GroupSettings } from '@/lib/api/schemas/models';
|
||||
import {
|
||||
type Model,
|
||||
type ManualModel,
|
||||
type GroupSettings,
|
||||
} from '@/lib/api/schemas/models';
|
||||
import { AdminService, type AdminModelGroup } from '@/lib/api/services/admin';
|
||||
AdminService,
|
||||
type AdminModelGroup,
|
||||
type AdminModel,
|
||||
} from '@/lib/api/services/admin';
|
||||
type ModelGroup = AdminModelGroup;
|
||||
import { AddModelForm } from '@/components/AddModelForm';
|
||||
import { EditModelForm } from '@/components/EditModelForm';
|
||||
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
|
||||
import { EditGroupForm } from '@/components/EditGroupForm';
|
||||
import { CollectModelsDialog } from '@/components/CollectModelsDialog';
|
||||
import { formatCost } from '@/lib/services/costValidation';
|
||||
@@ -80,14 +79,23 @@ export function ModelSelector({
|
||||
}: ModelSelectorProps) {
|
||||
const [selectedModelId, setSelectedModelId] = useState<string>('');
|
||||
const [, setHoveredModelId] = useState<string | null>(null);
|
||||
const [isAddFormOpen, setIsAddFormOpen] = useState(false);
|
||||
const [editingModel, setEditingModel] = useState<Model | null>(null);
|
||||
const [editingGroup, setEditingGroup] = useState<{
|
||||
provider: string;
|
||||
models: Model[];
|
||||
groupData: ModelGroup;
|
||||
} | null>(null);
|
||||
const [isCollectDialogOpen, setIsCollectDialogOpen] = useState(false);
|
||||
const [modelDialogState, setModelDialogState] = useState<{
|
||||
isOpen: boolean;
|
||||
providerId: number | null;
|
||||
mode: 'create' | 'edit' | 'override';
|
||||
initialData?: AdminModel | null;
|
||||
}>({
|
||||
isOpen: false,
|
||||
providerId: null,
|
||||
mode: 'create',
|
||||
initialData: null,
|
||||
});
|
||||
|
||||
// Bulk selection state
|
||||
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
|
||||
@@ -492,44 +500,106 @@ export function ModelSelector({
|
||||
return hasIndividualApiKey || hasIndividualUrl;
|
||||
};
|
||||
|
||||
// Handle manual model addition
|
||||
const handleAddModel = async (newModel: ManualModel) => {
|
||||
try {
|
||||
// Find or create the provider group
|
||||
let providerGroup = groups.find((g) => g.provider === newModel.provider);
|
||||
const handleAddModelClick = (providerId: number) => {
|
||||
setModelDialogState({
|
||||
isOpen: true,
|
||||
providerId,
|
||||
mode: 'create',
|
||||
initialData: null,
|
||||
});
|
||||
};
|
||||
|
||||
if (!providerGroup) {
|
||||
providerGroup = await AdminService.createModelGroup({
|
||||
provider: newModel.provider,
|
||||
group_api_key: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const modelData = {
|
||||
name: newModel.name,
|
||||
full_name: newModel.name,
|
||||
input_cost: newModel.input_cost,
|
||||
output_cost: newModel.output_cost,
|
||||
provider: newModel.provider,
|
||||
modelType: newModel.modelType,
|
||||
description: newModel.description || undefined,
|
||||
contextLength: newModel.contextLength,
|
||||
};
|
||||
|
||||
await AdminService.createModel(modelData);
|
||||
await refetchModels();
|
||||
toast.success(`Model "${newModel.name}" added successfully!`);
|
||||
} catch (error) {
|
||||
console.error('Error adding model:', error);
|
||||
toast.error('Failed to add model. Please try again.');
|
||||
throw error; // Re-throw to let the form handle the error
|
||||
const handleEditModelClick = (model: Model) => {
|
||||
if (!model.provider_id) {
|
||||
toast.error('Provider ID missing for model');
|
||||
return;
|
||||
}
|
||||
const providerId = parseInt(model.provider_id);
|
||||
|
||||
// Construct AdminModel from Model
|
||||
const adminModel: AdminModel = {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
description: model.description || '',
|
||||
created: new Date(model.createdAt).getTime() / 1000,
|
||||
context_length: model.contextLength || 0,
|
||||
architecture: {
|
||||
modality: model.modelType,
|
||||
input_modalities: [model.modelType],
|
||||
output_modalities: [model.modelType],
|
||||
tokenizer: '',
|
||||
instruct_type: null,
|
||||
},
|
||||
pricing: {
|
||||
prompt: model.input_cost,
|
||||
completion: model.output_cost,
|
||||
request: model.min_cost_per_request,
|
||||
image: 0,
|
||||
web_search: 0,
|
||||
internal_reasoning: 0,
|
||||
},
|
||||
per_request_limits: null,
|
||||
top_provider: null,
|
||||
upstream_provider_id: providerId,
|
||||
enabled: model.isEnabled,
|
||||
alias_ids: model.alias_ids || null,
|
||||
};
|
||||
|
||||
setModelDialogState({
|
||||
isOpen: true,
|
||||
providerId,
|
||||
mode: 'edit',
|
||||
initialData: adminModel,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOverrideModelClick = (model: Model) => {
|
||||
if (!model.provider_id) {
|
||||
toast.error('Provider ID missing for model');
|
||||
return;
|
||||
}
|
||||
const providerId = parseInt(model.provider_id);
|
||||
|
||||
// Construct AdminModel from Model
|
||||
const adminModel: AdminModel = {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
description: model.description || '',
|
||||
created: new Date(model.createdAt).getTime() / 1000,
|
||||
context_length: model.contextLength || 0,
|
||||
architecture: {
|
||||
modality: model.modelType,
|
||||
input_modalities: [model.modelType],
|
||||
output_modalities: [model.modelType],
|
||||
tokenizer: '',
|
||||
instruct_type: null,
|
||||
},
|
||||
pricing: {
|
||||
prompt: model.input_cost,
|
||||
completion: model.output_cost,
|
||||
request: model.min_cost_per_request,
|
||||
image: 0,
|
||||
web_search: 0,
|
||||
internal_reasoning: 0,
|
||||
},
|
||||
per_request_limits: null,
|
||||
top_provider: null,
|
||||
upstream_provider_id: providerId,
|
||||
enabled: model.isEnabled,
|
||||
alias_ids: model.alias_ids || null,
|
||||
};
|
||||
|
||||
setModelDialogState({
|
||||
isOpen: true,
|
||||
providerId,
|
||||
mode: 'override',
|
||||
initialData: adminModel,
|
||||
});
|
||||
};
|
||||
|
||||
// Handle model update
|
||||
const handleModelUpdate = async () => {
|
||||
await refetchModels();
|
||||
setEditingModel(null);
|
||||
};
|
||||
|
||||
// Handle group update
|
||||
@@ -832,11 +902,18 @@ export function ModelSelector({
|
||||
Delete All Overrides Permanently
|
||||
</Button>
|
||||
)}
|
||||
{/* Model Management Actions
|
||||
<Button onClick={() => setIsAddFormOpen(true)} className='gap-2'>
|
||||
<Plus className='h-4 w-4' />
|
||||
Add Model
|
||||
</Button>
|
||||
{/* Model Management Actions */}
|
||||
{groupData && (
|
||||
<Button
|
||||
onClick={() => handleAddModelClick(parseInt(groupData.id))}
|
||||
className='gap-2'
|
||||
variant='outline'
|
||||
>
|
||||
<CheckSquare className='h-4 w-4' />
|
||||
Add Custom Model
|
||||
</Button>
|
||||
)}
|
||||
{/*
|
||||
<Button
|
||||
onClick={() => setIsCollectDialogOpen(true)}
|
||||
variant='outline'
|
||||
@@ -1080,15 +1157,28 @@ export function ModelSelector({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingModel(model);
|
||||
}}
|
||||
>
|
||||
<Edit3 className='mr-2 h-4 w-4' />
|
||||
Edit Model
|
||||
</DropdownMenuItem>
|
||||
{model.api_key_type !== 'remote' && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditModelClick(model);
|
||||
}}
|
||||
>
|
||||
<Edit3 className='mr-2 h-4 w-4' />
|
||||
Edit Model
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{model.api_key_type === 'remote' && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOverrideModelClick(model);
|
||||
}}
|
||||
>
|
||||
<Edit3 className='mr-2 h-4 w-4' />
|
||||
Override
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{model.soft_deleted ? (
|
||||
<DropdownMenuItem
|
||||
@@ -1215,23 +1305,16 @@ export function ModelSelector({
|
||||
})}
|
||||
|
||||
{/* Forms and Dialogs */}
|
||||
<AddModelForm
|
||||
isOpen={isAddFormOpen}
|
||||
onModelAdd={handleAddModel}
|
||||
onCancel={() => setIsAddFormOpen(false)}
|
||||
/>
|
||||
|
||||
{editingModel && (
|
||||
<EditModelForm
|
||||
model={editingModel}
|
||||
providerId={
|
||||
editingModel.provider_id
|
||||
? parseInt(editingModel.provider_id)
|
||||
: undefined
|
||||
{modelDialogState.providerId && (
|
||||
<AddProviderModelDialog
|
||||
providerId={modelDialogState.providerId}
|
||||
isOpen={modelDialogState.isOpen}
|
||||
onClose={() =>
|
||||
setModelDialogState((prev) => ({ ...prev, isOpen: false }))
|
||||
}
|
||||
onModelUpdate={handleModelUpdate}
|
||||
onCancel={() => setEditingModel(null)}
|
||||
isOpen={!!editingModel}
|
||||
onSuccess={handleModelUpdate}
|
||||
initialData={modelDialogState.initialData}
|
||||
mode={modelDialogState.mode}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -48,20 +48,26 @@ export function CurrencyToggle() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-9 w-9 px-0 gap-2 w-auto px-3 font-normal">
|
||||
<Coins className="h-4 w-4" />
|
||||
<span className="hidden sm:inline-block">{getLabel(displayUnit)}</span>
|
||||
<span className="sm:hidden uppercase">{displayUnit}</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-9 w-9 w-auto gap-2 px-0 px-3 font-normal'
|
||||
>
|
||||
<Coins className='h-4 w-4' />
|
||||
<span className='hidden sm:inline-block'>
|
||||
{getLabel(displayUnit)}
|
||||
</span>
|
||||
<span className='uppercase sm:hidden'>{displayUnit}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={() => setDisplayUnit('msat')}>
|
||||
Millisatoshis (mSAT)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDisplayUnit('sat')}>
|
||||
Satoshis (sat)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayUnit('usd')}
|
||||
disabled={!usdPerSat}
|
||||
>
|
||||
@@ -71,4 +77,3 @@ export function CurrencyToggle() {
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,4 +96,3 @@ export function DashboardBalanceSummary({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ErrorDetailsTable({ errors }: ErrorDetailsTableProps) {
|
||||
<CardTitle>Recent Errors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-muted-foreground text-center py-8'>
|
||||
<p className='text-muted-foreground py-8 text-center'>
|
||||
No errors found in the selected time period
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
'use client';
|
||||
|
||||
import { type JSX, useCallback, useState } from 'react';
|
||||
import { Copy, RefreshCcw, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
type WalletSnapshot = {
|
||||
apiKey: string;
|
||||
balanceMsats: number;
|
||||
reservedMsats: number;
|
||||
};
|
||||
|
||||
type RefundReceipt = {
|
||||
token?: string;
|
||||
recipient?: string;
|
||||
sats?: string;
|
||||
msats?: string;
|
||||
};
|
||||
|
||||
interface ApiKeyManagerProps {
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
walletInfo?: WalletSnapshot | null;
|
||||
onApiKeyChanged?: (apiKey: string) => void;
|
||||
onWalletInfoUpdated?: (walletInfo: WalletSnapshot | null) => void;
|
||||
onRefundComplete?: (receipt: RefundReceipt) => void;
|
||||
}
|
||||
|
||||
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 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 ApiKeyManager({
|
||||
baseUrl,
|
||||
apiKey = '',
|
||||
walletInfo = null,
|
||||
onApiKeyChanged,
|
||||
onWalletInfoUpdated,
|
||||
onRefundComplete,
|
||||
}: ApiKeyManagerProps): JSX.Element {
|
||||
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
|
||||
const [isSyncingBalance, setIsSyncingBalance] = useState(false);
|
||||
const [isRefunding, setIsRefunding] = useState(false);
|
||||
const [hasInteractedManage, setHasInteractedManage] = useState(false);
|
||||
|
||||
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 handleSyncBalance = useCallback(async (): Promise<void> => {
|
||||
const activeApiKey = apiKeyInput.trim();
|
||||
if (!activeApiKey) {
|
||||
toast.error('Paste an API key first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSyncingBalance(true);
|
||||
try {
|
||||
const snapshot = await fetchWalletInfo(baseUrl, activeApiKey);
|
||||
onWalletInfoUpdated?.(snapshot);
|
||||
toast.success('Balance synced');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to sync balance'
|
||||
);
|
||||
} finally {
|
||||
setIsSyncingBalance(false);
|
||||
}
|
||||
}, [apiKeyInput, baseUrl, onWalletInfoUpdated]);
|
||||
|
||||
const handleRefund = useCallback(async (): Promise<void> => {
|
||||
const activeApiKey = apiKeyInput.trim();
|
||||
if (!activeApiKey) {
|
||||
toast.error('Paste an API key first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRefunding(true);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/balance/refund`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${activeApiKey}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Refund failed');
|
||||
}
|
||||
const receipt = (await response.json()) as RefundReceipt;
|
||||
onRefundComplete?.(receipt);
|
||||
onWalletInfoUpdated?.(null);
|
||||
setApiKeyInput('');
|
||||
toast.success('Refund completed');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(error instanceof Error ? error.message : 'Refund failed');
|
||||
} finally {
|
||||
setIsRefunding(false);
|
||||
}
|
||||
}, [apiKeyInput, baseUrl, onRefundComplete, onWalletInfoUpdated]);
|
||||
|
||||
const handleApiKeyChange = useCallback(
|
||||
(newKey: string) => {
|
||||
setApiKeyInput(newKey);
|
||||
onApiKeyChanged?.(newKey);
|
||||
if (newKey !== apiKey) {
|
||||
onWalletInfoUpdated?.(null);
|
||||
}
|
||||
},
|
||||
[apiKey, onApiKeyChanged, onWalletInfoUpdated]
|
||||
);
|
||||
|
||||
const activeApiKey = apiKeyInput.trim();
|
||||
const showManageDetails =
|
||||
hasInteractedManage || Boolean(walletInfo) || activeApiKey.length > 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='space-y-1'>
|
||||
<CardTitle className='flex items-center gap-2 text-xl'>
|
||||
<RefreshCcw className='text-primary h-5 w-5' />
|
||||
API Key Management
|
||||
</CardTitle>
|
||||
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
Manage your existing API keys and balances
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
<section className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<span>Manage existing 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) => handleApiKeyChange(event.target.value)}
|
||||
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='space-y-4'>
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
|
||||
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-muted-foreground text-[0.65rem] tracking-wide uppercase'>
|
||||
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>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<span>Refund remaining balance</span>
|
||||
</header>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
onClick={handleRefund}
|
||||
disabled={isRefunding || !activeApiKey}
|
||||
variant='destructive'
|
||||
className='gap-2'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
{isRefunding ? 'Processing...' : 'Refund & Delete Key'}
|
||||
</Button>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
Burns the key and returns a fresh Cashu token.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
'use client';
|
||||
|
||||
import { type JSX, useCallback, useState } from 'react';
|
||||
import { Copy, KeyRound, RefreshCcw, Trash2 } 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 { Separator } from '@/components/ui/separator';
|
||||
|
||||
type WalletSnapshot = {
|
||||
apiKey: string;
|
||||
balanceMsats: number;
|
||||
reservedMsats: number;
|
||||
};
|
||||
|
||||
type RefundReceipt = {
|
||||
token?: string;
|
||||
recipient?: string;
|
||||
sats?: string;
|
||||
msats?: string;
|
||||
};
|
||||
|
||||
interface CashuPaymentWorkflowProps {
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
walletInfo?: WalletSnapshot | null;
|
||||
onApiKeyCreated?: (apiKey: string, walletInfo: WalletSnapshot) => void;
|
||||
onApiKeyChanged?: (apiKey: string) => void;
|
||||
onWalletInfoUpdated?: (walletInfo: WalletSnapshot | null) => void;
|
||||
onRefundComplete?: (receipt: RefundReceipt) => void;
|
||||
}
|
||||
|
||||
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 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 CashuPaymentWorkflow({
|
||||
baseUrl,
|
||||
apiKey = '',
|
||||
walletInfo = null,
|
||||
onApiKeyCreated,
|
||||
onApiKeyChanged,
|
||||
onWalletInfoUpdated,
|
||||
onRefundComplete,
|
||||
}: CashuPaymentWorkflowProps): JSX.Element {
|
||||
const [initialToken, setInitialToken] = useState('');
|
||||
const [topupToken, setTopupToken] = useState('');
|
||||
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
|
||||
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);
|
||||
|
||||
const activeApiKey = apiKeyInput.trim();
|
||||
|
||||
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);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
initial_balance_token: initialToken.trim(),
|
||||
});
|
||||
const response = await fetch(
|
||||
`${baseUrl}/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);
|
||||
onApiKeyCreated?.(snapshot.apiKey, 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, baseUrl, onApiKeyCreated]);
|
||||
|
||||
const handleSyncBalance = useCallback(async (): Promise<void> => {
|
||||
if (!activeApiKey) {
|
||||
toast.error('Paste an API key first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSyncingBalance(true);
|
||||
try {
|
||||
const snapshot = await fetchWalletInfo(baseUrl, activeApiKey);
|
||||
onWalletInfoUpdated?.(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, baseUrl, onWalletInfoUpdated]);
|
||||
|
||||
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);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/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(baseUrl, activeApiKey);
|
||||
onApiKeyCreated?.(snapshot.apiKey, snapshot);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(error instanceof Error ? error.message : 'Top-up failed');
|
||||
} finally {
|
||||
setIsTopupLoading(false);
|
||||
}
|
||||
}, [activeApiKey, baseUrl, topupToken, onApiKeyCreated]);
|
||||
|
||||
const handleRefund = useCallback(async (): Promise<void> => {
|
||||
if (!activeApiKey) {
|
||||
toast.error('Paste an API key first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRefunding(true);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/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;
|
||||
onRefundComplete?.(payload);
|
||||
onWalletInfoUpdated?.(null);
|
||||
setApiKeyInput('');
|
||||
toast.success('Refund requested');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(error instanceof Error ? error.message : 'Refund failed');
|
||||
} finally {
|
||||
setIsRefunding(false);
|
||||
}
|
||||
}, [activeApiKey, baseUrl, onRefundComplete, onWalletInfoUpdated]);
|
||||
|
||||
const handleApiKeyChange = useCallback(
|
||||
(newKey: string) => {
|
||||
setApiKeyInput(newKey);
|
||||
onApiKeyChanged?.(newKey);
|
||||
if (newKey !== apiKey) {
|
||||
onWalletInfoUpdated?.(null);
|
||||
}
|
||||
},
|
||||
[apiKey, onApiKeyChanged, onWalletInfoUpdated]
|
||||
);
|
||||
|
||||
const showCreateDetails =
|
||||
hasInteractedCreate || initialToken.trim().length > 0;
|
||||
const showManageDetails = hasInteractedManage || Boolean(walletInfo);
|
||||
const showTopupDetails = hasInteractedTopup || topupToken.trim().length > 0;
|
||||
const canTopup = Boolean(activeApiKey);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='space-y-1'>
|
||||
<CardTitle className='flex items-center gap-2 text-xl'>
|
||||
<KeyRound className='text-primary h-5 w-5' />
|
||||
API key workflow
|
||||
</CardTitle>
|
||||
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
Sections expand as soon as you interact
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
<section className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<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-muted-foreground text-xs'>
|
||||
Redeems instantly and returns <code>sk-</code> key.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<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) => handleApiKeyChange(event.target.value)}
|
||||
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-muted-foreground text-[0.65rem] tracking-wide uppercase'>
|
||||
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-muted-foreground text-[0.65rem] tracking-wide uppercase'>
|
||||
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='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<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-muted-foreground text-xs'>
|
||||
{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='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<span>4 · Refund</span>
|
||||
</header>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
onClick={handleRefund}
|
||||
disabled={isRefunding || !activeApiKey}
|
||||
variant='destructive'
|
||||
className='gap-2'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
{isRefunding ? 'Processing…' : 'Refund remaining balance'}
|
||||
</Button>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
Burns the key and returns a fresh Cashu token.
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,22 +2,19 @@
|
||||
|
||||
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 { Bolt, Copy, 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
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;
|
||||
@@ -27,6 +24,7 @@ type NodeInfo = {
|
||||
mints: string[];
|
||||
http_url?: string | null;
|
||||
onion_url?: string | null;
|
||||
child_key_cost_msats?: number;
|
||||
};
|
||||
|
||||
type WalletSnapshot = {
|
||||
@@ -62,36 +60,6 @@ async function fetchNodeInfo(baseUrl: string): Promise<NodeInfo> {
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -100,30 +68,15 @@ function normalizeBaseUrl(url: string): string {
|
||||
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);
|
||||
const [refundReceipt, setRefundReceipt] = useState<RefundReceipt | null>(
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!baseUrl && typeof window !== 'undefined') {
|
||||
@@ -136,8 +89,6 @@ export function CheatSheet(): JSX.Element {
|
||||
[baseUrl]
|
||||
);
|
||||
|
||||
const activeApiKey = apiKeyInput.trim();
|
||||
|
||||
const {
|
||||
data: nodeInfo,
|
||||
isLoading: isInfoLoading,
|
||||
@@ -168,141 +119,28 @@ export function CheatSheet(): JSX.Element {
|
||||
}
|
||||
}, []);
|
||||
|
||||
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);
|
||||
const handleApiKeyCreated = useCallback(
|
||||
(apiKey: string, snapshot: WalletSnapshot) => {
|
||||
setApiKeyInput(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]);
|
||||
setRefundReceipt(null);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSyncBalance = useCallback(async (): Promise<void> => {
|
||||
if (!activeApiKey) {
|
||||
toast.error('Paste an API key first');
|
||||
return;
|
||||
}
|
||||
const handleApiKeyChanged = useCallback((apiKey: string) => {
|
||||
setApiKeyInput(apiKey);
|
||||
}, []);
|
||||
|
||||
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 handleWalletInfoUpdated = useCallback((info: WalletSnapshot | null) => {
|
||||
setWalletInfo(info);
|
||||
}, []);
|
||||
|
||||
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 handleRefundComplete = useCallback((receipt: RefundReceipt) => {
|
||||
setRefundReceipt(receipt);
|
||||
setWalletInfo(null);
|
||||
setApiKeyInput('');
|
||||
}, []);
|
||||
|
||||
const handleRefreshInfo = useCallback(async (): Promise<void> => {
|
||||
const result = await refetchNodeInfo();
|
||||
@@ -314,7 +152,7 @@ export function CheatSheet(): JSX.Element {
|
||||
}, [refetchNodeInfo]);
|
||||
|
||||
const curlSnippet = useMemo(() => {
|
||||
const keyPreview = activeApiKey || 'YOUR_API_KEY';
|
||||
const keyPreview = apiKeyInput || 'YOUR_API_KEY';
|
||||
return [
|
||||
`curl -X POST "${normalizedBaseUrl}/v1/chat/completions"`,
|
||||
` -H "Authorization: Bearer ${keyPreview}"`,
|
||||
@@ -327,27 +165,21 @@ export function CheatSheet(): JSX.Element {
|
||||
' ]',
|
||||
" }'",
|
||||
].join('\n');
|
||||
}, [activeApiKey, normalizedBaseUrl]);
|
||||
}, [apiKeyInput, 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'>
|
||||
<div className='from-background via-background to-muted min-h-screen bg-gradient-to-b'>
|
||||
<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'>
|
||||
<div className='absolute top-0 right-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' />
|
||||
<div className='text-muted-foreground inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] tracking-wider uppercase'>
|
||||
<Bolt className='text-primary h-4 w-4' />
|
||||
Routstr cheat sheet
|
||||
</div>
|
||||
<h1 className='text-3xl font-semibold tracking-tight sm:text-4xl'>
|
||||
@@ -365,10 +197,10 @@ export function CheatSheet(): JSX.Element {
|
||||
<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' />
|
||||
<ShieldCheck className='text-primary h-4 w-4' />
|
||||
Node identity
|
||||
</CardTitle>
|
||||
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
|
||||
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
/v1/info snapshot
|
||||
</p>
|
||||
</div>
|
||||
@@ -404,7 +236,7 @@ export function CheatSheet(): JSX.Element {
|
||||
</div>
|
||||
<dl className='grid gap-4 sm:grid-cols-2'>
|
||||
<div>
|
||||
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
Version
|
||||
</dt>
|
||||
<dd className='text-base font-medium'>
|
||||
@@ -412,7 +244,7 @@ export function CheatSheet(): JSX.Element {
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
HTTP
|
||||
</dt>
|
||||
<dd className='text-base font-medium break-all'>
|
||||
@@ -421,7 +253,7 @@ export function CheatSheet(): JSX.Element {
|
||||
</div>
|
||||
{nodeInfo.onion_url && (
|
||||
<div className='sm:col-span-2'>
|
||||
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
Onion
|
||||
</dt>
|
||||
<dd className='text-base font-medium break-all'>
|
||||
@@ -431,10 +263,10 @@ export function CheatSheet(): JSX.Element {
|
||||
)}
|
||||
{nodeInfo.npub && (
|
||||
<div className='sm:col-span-2'>
|
||||
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
npub
|
||||
</dt>
|
||||
<dd className='flex items-center gap-2 break-all text-sm font-mono'>
|
||||
<dd className='flex items-center gap-2 font-mono text-sm break-all'>
|
||||
{nodeInfo.npub}
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -449,7 +281,7 @@ export function CheatSheet(): JSX.Element {
|
||||
)}
|
||||
</dl>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
|
||||
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
Cashu mints
|
||||
</p>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
@@ -478,10 +310,10 @@ export function CheatSheet(): JSX.Element {
|
||||
<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' />
|
||||
<Terminal className='text-primary h-4 w-4' />
|
||||
Quick docs
|
||||
</CardTitle>
|
||||
<span className='text-xs uppercase tracking-wide text-muted-foreground'>
|
||||
<span className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
curl-ready
|
||||
</span>
|
||||
</CardHeader>
|
||||
@@ -502,8 +334,10 @@ export function CheatSheet(): JSX.Element {
|
||||
<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 className='bg-muted rounded-lg p-4 font-mono text-sm leading-6'>
|
||||
<pre className='break-all whitespace-pre-wrap'>
|
||||
{curlSnippet}
|
||||
</pre>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
@@ -529,218 +363,79 @@ export function CheatSheet(): JSX.Element {
|
||||
</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>
|
||||
<Tabs defaultValue='cashu' className='w-full'>
|
||||
<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>
|
||||
|
||||
<Separator />
|
||||
<TabsContent value='cashu' className='space-y-4'>
|
||||
<CashuPaymentWorkflow
|
||||
baseUrl={normalizedBaseUrl}
|
||||
onApiKeyCreated={handleApiKeyCreated}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<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>
|
||||
)}
|
||||
<TabsContent value='lightning' className='space-y-4'>
|
||||
<LightningPaymentWorkflow
|
||||
baseUrl={normalizedBaseUrl}
|
||||
onApiKeyCreated={handleApiKeyCreated}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='manage' className='space-y-4'>
|
||||
<ApiKeyManager
|
||||
baseUrl={normalizedBaseUrl}
|
||||
apiKey={apiKeyInput}
|
||||
walletInfo={walletInfo}
|
||||
onApiKeyChanged={handleApiKeyChanged}
|
||||
onWalletInfoUpdated={handleWalletInfoUpdated}
|
||||
onRefundComplete={handleRefundComplete}
|
||||
/>
|
||||
|
||||
{refundToken && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-lg'>Refund Complete</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-2'>
|
||||
<div className='bg-muted/30 space-y-2 rounded-lg border p-4'>
|
||||
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<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>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
'use client';
|
||||
|
||||
import { type JSX, useCallback, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Copy, Zap, CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import QRCode from 'qrcode';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
type WalletSnapshot = {
|
||||
apiKey: string;
|
||||
balanceMsats: number;
|
||||
reservedMsats: number;
|
||||
};
|
||||
|
||||
type LightningInvoice = {
|
||||
invoice_id: string;
|
||||
bolt11: string;
|
||||
amount_sats: number;
|
||||
expires_at: number;
|
||||
payment_hash: string;
|
||||
};
|
||||
|
||||
type InvoiceStatus = {
|
||||
status: string;
|
||||
api_key?: string;
|
||||
amount_sats: number;
|
||||
paid_at?: number;
|
||||
created_at: number;
|
||||
expires_at: number;
|
||||
};
|
||||
|
||||
interface LightningPaymentWorkflowProps {
|
||||
baseUrl: string;
|
||||
onApiKeyCreated?: (apiKey: string, walletInfo: WalletSnapshot) => void;
|
||||
}
|
||||
|
||||
function formatSats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
|
||||
}
|
||||
|
||||
async function generateQRCodeSVG(text: string): Promise<string> {
|
||||
try {
|
||||
return await QRCode.toDataURL(text, {
|
||||
type: 'image/png',
|
||||
width: 200,
|
||||
margin: 1,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function LightningPaymentWorkflow({
|
||||
baseUrl,
|
||||
onApiKeyCreated,
|
||||
}: LightningPaymentWorkflowProps): JSX.Element {
|
||||
const [createAmount, setCreateAmount] = useState<string>('');
|
||||
const [topupAmount, setTopupAmount] = useState<string>('');
|
||||
const [topupApiKey, setTopupApiKey] = useState<string>('');
|
||||
const [recoverInvoice, setRecoverInvoice] = useState<string>('');
|
||||
|
||||
const [createInvoice, setCreateInvoice] = useState<LightningInvoice | null>(
|
||||
null
|
||||
);
|
||||
const [topupInvoice, setTopupInvoice] = useState<LightningInvoice | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const [createQRCode, setCreateQRCode] = useState<string>('');
|
||||
const [topupQRCode, setTopupQRCode] = useState<string>('');
|
||||
|
||||
const [createdApiKey, setCreatedApiKey] = useState<string>('');
|
||||
const [topupApiKeyResult, setTopupApiKeyResult] = useState<string>('');
|
||||
const [recoveredApiKey, setRecoveredApiKey] = useState<string>('');
|
||||
const [isWaitingPayment, setIsWaitingPayment] = useState(false);
|
||||
const [isWaitingTopupPayment, setIsWaitingTopupPayment] = useState(false);
|
||||
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isTopupping, setIsTopupping] = useState(false);
|
||||
const [isRecovering, setIsRecovering] = useState(false);
|
||||
|
||||
const [hasInteractedCreate, setHasInteractedCreate] = useState(false);
|
||||
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
|
||||
const [hasInteractedRecover, setHasInteractedRecover] = useState(false);
|
||||
|
||||
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 pollInvoiceStatus = useCallback(
|
||||
async (invoiceId: string, onPaid: (status: InvoiceStatus) => void) => {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60; // 5 minutes with 5 second intervals
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/v1/balance/lightning/invoice/${invoiceId}/status`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to check invoice status');
|
||||
}
|
||||
|
||||
const status: InvoiceStatus = await response.json();
|
||||
|
||||
if (status.status === 'paid' && status.api_key) {
|
||||
onPaid(status);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === 'expired' || status.status === 'cancelled') {
|
||||
toast.error('Invoice expired or cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(poll, 5000); // Poll every 5 seconds
|
||||
} else {
|
||||
toast.error('Payment timeout - please check manually');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to poll invoice status:', error);
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(poll, 5000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
},
|
||||
[baseUrl]
|
||||
);
|
||||
|
||||
const handleCreateInvoice = useCallback(async (): Promise<void> => {
|
||||
const amount = parseInt(createAmount);
|
||||
if (!amount || amount <= 0) {
|
||||
toast.error('Please enter a valid amount');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/balance/lightning/invoice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount_sats: amount,
|
||||
purpose: 'create',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Failed to create invoice');
|
||||
}
|
||||
|
||||
const invoice: LightningInvoice = await response.json();
|
||||
setCreateInvoice(invoice);
|
||||
|
||||
const qrCode = await generateQRCodeSVG(invoice.bolt11);
|
||||
setCreateQRCode(qrCode);
|
||||
setIsWaitingPayment(true);
|
||||
|
||||
toast.success('Lightning invoice created - waiting for payment...');
|
||||
|
||||
pollInvoiceStatus(invoice.invoice_id, (status) => {
|
||||
if (status.api_key) {
|
||||
const walletInfo: WalletSnapshot = {
|
||||
apiKey: status.api_key,
|
||||
balanceMsats: status.amount_sats * 1000,
|
||||
reservedMsats: 0,
|
||||
};
|
||||
onApiKeyCreated?.(status.api_key, walletInfo);
|
||||
setCreatedApiKey(status.api_key);
|
||||
setIsWaitingPayment(false);
|
||||
toast.success('Payment received! API key created.');
|
||||
setCreateInvoice(null);
|
||||
setCreateAmount('');
|
||||
setCreateQRCode('');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to create invoice'
|
||||
);
|
||||
setIsWaitingPayment(false);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [createAmount, baseUrl, pollInvoiceStatus, onApiKeyCreated]);
|
||||
|
||||
const handleTopupInvoice = useCallback(async (): Promise<void> => {
|
||||
const amount = parseInt(topupAmount);
|
||||
if (!amount || amount <= 0) {
|
||||
toast.error('Please enter a valid amount');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!topupApiKey.trim()) {
|
||||
toast.error('Please enter your API key');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTopupping(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/balance/lightning/invoice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount_sats: amount,
|
||||
purpose: 'topup',
|
||||
api_key: topupApiKey.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Failed to create topup invoice');
|
||||
}
|
||||
|
||||
const invoice: LightningInvoice = await response.json();
|
||||
setTopupInvoice(invoice);
|
||||
|
||||
const qrCode = await generateQRCodeSVG(invoice.bolt11);
|
||||
setTopupQRCode(qrCode);
|
||||
setIsWaitingTopupPayment(true);
|
||||
|
||||
toast.success('Lightning topup invoice created - waiting for payment...');
|
||||
|
||||
pollInvoiceStatus(invoice.invoice_id, (status) => {
|
||||
if (status.api_key) {
|
||||
const walletInfo: WalletSnapshot = {
|
||||
apiKey: status.api_key,
|
||||
balanceMsats: status.amount_sats * 1000,
|
||||
reservedMsats: 0,
|
||||
};
|
||||
onApiKeyCreated?.(status.api_key, walletInfo);
|
||||
setTopupApiKeyResult(status.api_key);
|
||||
setIsWaitingTopupPayment(false);
|
||||
toast.success(
|
||||
`Payment received! Added ${formatSats(status.amount_sats * 1000)} sats.`
|
||||
);
|
||||
setTopupInvoice(null);
|
||||
setTopupAmount('');
|
||||
setTopupQRCode('');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to create topup invoice'
|
||||
);
|
||||
setIsWaitingTopupPayment(false);
|
||||
} finally {
|
||||
setIsTopupping(false);
|
||||
}
|
||||
}, [topupAmount, topupApiKey, baseUrl, pollInvoiceStatus, onApiKeyCreated]);
|
||||
|
||||
const handleRecoverInvoice = useCallback(async (): Promise<void> => {
|
||||
if (!recoverInvoice.trim()) {
|
||||
toast.error('Please enter a BOLT11 invoice');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRecovering(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/balance/lightning/recover`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
bolt11: recoverInvoice.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Invoice not found or not paid');
|
||||
}
|
||||
|
||||
const status: InvoiceStatus = await response.json();
|
||||
|
||||
if (status.status === 'paid' && status.api_key) {
|
||||
const walletInfo: WalletSnapshot = {
|
||||
apiKey: status.api_key,
|
||||
balanceMsats: status.amount_sats * 1000,
|
||||
reservedMsats: 0,
|
||||
};
|
||||
onApiKeyCreated?.(status.api_key, walletInfo);
|
||||
setRecoveredApiKey(status.api_key);
|
||||
toast.success('API key recovered successfully!');
|
||||
setRecoverInvoice('');
|
||||
} else {
|
||||
toast.error(`Invoice status: ${status.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to recover invoice'
|
||||
);
|
||||
} finally {
|
||||
setIsRecovering(false);
|
||||
}
|
||||
}, [recoverInvoice, baseUrl, onApiKeyCreated]);
|
||||
|
||||
const showCreateDetails =
|
||||
hasInteractedCreate || createAmount.trim().length > 0;
|
||||
const showTopupDetails =
|
||||
hasInteractedTopup ||
|
||||
topupAmount.trim().length > 0 ||
|
||||
topupApiKey.trim().length > 0;
|
||||
const showRecoverDetails =
|
||||
hasInteractedRecover || recoverInvoice.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='space-y-1'>
|
||||
<CardTitle className='flex items-center gap-2 text-xl'>
|
||||
<Zap className='text-primary h-5 w-5' />
|
||||
Lightning Payment Workflow
|
||||
</CardTitle>
|
||||
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
Create and manage API keys using Lightning Network payments
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
<section className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<span>1 · Create key with Lightning</span>
|
||||
{showCreateDetails && (
|
||||
<span className='text-primary'>Amount specified</span>
|
||||
)}
|
||||
</header>
|
||||
<Input
|
||||
type='number'
|
||||
value={createAmount}
|
||||
onChange={(event) => setCreateAmount(event.target.value)}
|
||||
placeholder='Amount in sats (e.g., 1000)'
|
||||
className='text-sm'
|
||||
onFocus={() => setHasInteractedCreate(true)}
|
||||
/>
|
||||
{showCreateDetails && (
|
||||
<div className='space-y-3'>
|
||||
<Button
|
||||
onClick={handleCreateInvoice}
|
||||
disabled={isCreating || !!createInvoice}
|
||||
className='gap-2'
|
||||
>
|
||||
{isCreating
|
||||
? 'Creating invoice...'
|
||||
: 'Create Lightning Invoice'}
|
||||
</Button>
|
||||
|
||||
{createInvoice && (
|
||||
<div className='bg-muted/30 space-y-3 rounded-lg border p-4'>
|
||||
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<span>
|
||||
Lightning Invoice ({createInvoice.amount_sats} sats)
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='gap-1 text-xs'
|
||||
onClick={() => handleCopy(createInvoice.bolt11)}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isWaitingPayment && (
|
||||
<div className='flex items-center justify-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950'>
|
||||
<Loader2 className='h-4 w-4 animate-spin text-orange-600' />
|
||||
<span className='text-sm font-medium text-orange-800 dark:text-orange-200'>
|
||||
Waiting for payment...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createQRCode && (
|
||||
<div className='flex justify-center'>
|
||||
<Image
|
||||
src={createQRCode}
|
||||
alt='QR Code'
|
||||
className='h-48 w-48'
|
||||
width={192}
|
||||
height={192}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
value={createInvoice.bolt11}
|
||||
readOnly
|
||||
rows={4}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
|
||||
<p className='text-muted-foreground text-center text-xs'>
|
||||
Scan QR code or copy invoice. Payment will be detected
|
||||
automatically.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdApiKey && (
|
||||
<div className='space-y-3 rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-950'>
|
||||
<div className='flex items-center justify-between text-[0.7rem] tracking-wider text-green-700 uppercase dark:text-green-300'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<CheckCircle className='h-4 w-4' />
|
||||
<span>API Key Created Successfully</span>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='gap-1 border-green-300 text-xs hover:bg-green-100 dark:border-green-700 dark:hover:bg-green-900'
|
||||
onClick={() => handleCopy(createdApiKey)}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={createdApiKey}
|
||||
readOnly
|
||||
rows={2}
|
||||
className='border-green-200 bg-white font-mono text-xs dark:border-green-800 dark:bg-green-950/50'
|
||||
/>
|
||||
|
||||
<div className='flex items-center justify-between text-xs text-green-700 dark:text-green-300'>
|
||||
<span>Your API key is ready to use!</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs text-green-700 hover:text-green-800 dark:text-green-300 dark:hover:text-green-200'
|
||||
onClick={() => setCreatedApiKey('')}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<span>2 · Top up existing key</span>
|
||||
{showTopupDetails && (
|
||||
<span className='text-primary'>Ready to create invoice</span>
|
||||
)}
|
||||
</header>
|
||||
<div className='space-y-2'>
|
||||
<Input
|
||||
value={topupApiKey}
|
||||
onChange={(event) => setTopupApiKey(event.target.value)}
|
||||
placeholder='Your API key (sk-...)'
|
||||
className='font-mono text-sm'
|
||||
onFocus={() => setHasInteractedTopup(true)}
|
||||
/>
|
||||
<Input
|
||||
type='number'
|
||||
value={topupAmount}
|
||||
onChange={(event) => setTopupAmount(event.target.value)}
|
||||
placeholder='Amount to add in sats'
|
||||
className='text-sm'
|
||||
onFocus={() => setHasInteractedTopup(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showTopupDetails && (
|
||||
<div className='space-y-3'>
|
||||
<Button
|
||||
onClick={handleTopupInvoice}
|
||||
disabled={isTopupping || !!topupInvoice}
|
||||
variant='outline'
|
||||
className='gap-2'
|
||||
>
|
||||
{isTopupping ? 'Creating invoice...' : 'Create Topup Invoice'}
|
||||
</Button>
|
||||
|
||||
{topupInvoice && (
|
||||
<div className='bg-muted/30 space-y-3 rounded-lg border p-4'>
|
||||
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<span>Topup Invoice ({topupInvoice.amount_sats} sats)</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='gap-1 text-xs'
|
||||
onClick={() => handleCopy(topupInvoice.bolt11)}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{topupQRCode && (
|
||||
<div className='flex justify-center'>
|
||||
<Image
|
||||
src={topupQRCode}
|
||||
alt='QR Code'
|
||||
className='h-48 w-48'
|
||||
width={192}
|
||||
height={192}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
value={topupInvoice.bolt11}
|
||||
readOnly
|
||||
rows={4}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
|
||||
<p className='text-muted-foreground text-center text-xs'>
|
||||
Scan QR code or copy invoice. Balance will be added
|
||||
automatically.
|
||||
</p>
|
||||
|
||||
{isWaitingTopupPayment && (
|
||||
<div className='flex items-center justify-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950'>
|
||||
<Loader2 className='h-4 w-4 animate-spin text-orange-600' />
|
||||
<span className='text-sm font-medium text-orange-800 dark:text-orange-200'>
|
||||
Waiting for payment...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{topupApiKeyResult && (
|
||||
<div className='space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950'>
|
||||
<div className='flex items-center justify-between text-[0.7rem] tracking-wider text-blue-700 uppercase dark:text-blue-300'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<CheckCircle className='h-4 w-4' />
|
||||
<span>Topup Successful</span>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='gap-1 border-blue-300 text-xs hover:bg-blue-100 dark:border-blue-700 dark:hover:bg-blue-900'
|
||||
onClick={() => handleCopy(topupApiKeyResult)}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={topupApiKeyResult}
|
||||
readOnly
|
||||
rows={2}
|
||||
className='border-blue-200 bg-white font-mono text-xs dark:border-blue-800 dark:bg-blue-950/50'
|
||||
/>
|
||||
|
||||
<div className='flex items-center justify-between text-xs text-blue-700 dark:text-blue-300'>
|
||||
<span>Balance has been added to your API key!</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs text-blue-700 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-200'
|
||||
onClick={() => setTopupApiKeyResult('')}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<span>3 · Recover from invoice</span>
|
||||
{showRecoverDetails && (
|
||||
<span className='text-primary'>Invoice provided</span>
|
||||
)}
|
||||
</header>
|
||||
<Textarea
|
||||
value={recoverInvoice}
|
||||
onChange={(event) => setRecoverInvoice(event.target.value)}
|
||||
placeholder='Paste BOLT11 invoice to recover API key...'
|
||||
rows={showRecoverDetails ? 3 : 1}
|
||||
className='font-mono text-sm transition-all duration-200'
|
||||
onFocus={() => setHasInteractedRecover(true)}
|
||||
/>
|
||||
{showRecoverDetails && (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
onClick={handleRecoverInvoice}
|
||||
disabled={isRecovering}
|
||||
variant='secondary'
|
||||
className='gap-2'
|
||||
>
|
||||
{isRecovering ? 'Recovering...' : 'Recover API Key'}
|
||||
</Button>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
Recovers API key from a paid Lightning invoice.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recoveredApiKey && (
|
||||
<div className='space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-4 dark:border-purple-800 dark:bg-purple-950'>
|
||||
<div className='flex items-center justify-between text-[0.7rem] tracking-wider text-purple-700 uppercase dark:text-purple-300'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<CheckCircle className='h-4 w-4' />
|
||||
<span>API Key Recovered</span>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='gap-1 border-purple-300 text-xs hover:bg-purple-100 dark:border-purple-700 dark:hover:bg-purple-900'
|
||||
onClick={() => handleCopy(recoveredApiKey)}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={recoveredApiKey}
|
||||
readOnly
|
||||
rows={2}
|
||||
className='border-purple-200 bg-white font-mono text-xs dark:border-purple-800 dark:bg-purple-950/50'
|
||||
/>
|
||||
|
||||
<div className='flex items-center justify-between text-xs text-purple-700 dark:text-purple-300'>
|
||||
<span>Your recovered API key is ready to use!</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs text-purple-700 hover:text-purple-800 dark:text-purple-300 dark:hover:text-purple-200'
|
||||
onClick={() => setRecoveredApiKey('')}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -41,8 +41,11 @@ export function RevenueByModelTable({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Revenue by Model</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Total Revenue: <span className="font-mono font-medium text-foreground">{formatAmount(totalRevenue)}</span>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Total Revenue:{' '}
|
||||
<span className='text-foreground font-mono font-medium'>
|
||||
{formatAmount(totalRevenue)}
|
||||
</span>
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -50,48 +53,62 @@ export function RevenueByModelTable({
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead className="text-right">Requests</TableHead>
|
||||
<TableHead className="text-right">Successful</TableHead>
|
||||
<TableHead className="text-right">Failed</TableHead>
|
||||
<TableHead className="text-right">Revenue</TableHead>
|
||||
<TableHead className="w-[100px]">Share</TableHead>
|
||||
<TableHead className="text-right">Refunds</TableHead>
|
||||
<TableHead className="text-right">Net Revenue</TableHead>
|
||||
<TableHead className="text-right">Avg/Request</TableHead>
|
||||
<TableHead className='text-right'>Requests</TableHead>
|
||||
<TableHead className='text-right'>Successful</TableHead>
|
||||
<TableHead className='text-right'>Failed</TableHead>
|
||||
<TableHead className='text-right'>Revenue</TableHead>
|
||||
<TableHead className='w-[100px]'>Share</TableHead>
|
||||
<TableHead className='text-right'>Refunds</TableHead>
|
||||
<TableHead className='text-right'>Net Revenue</TableHead>
|
||||
<TableHead className='text-right'>Avg/Request</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{models.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center text-muted-foreground">
|
||||
<TableCell
|
||||
colSpan={9}
|
||||
className='text-muted-foreground text-center'
|
||||
>
|
||||
No model data available
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
models.map((model) => {
|
||||
const share = totalRevenue > 0 ? (model.revenue_sats / totalRevenue) * 100 : 0;
|
||||
const share =
|
||||
totalRevenue > 0
|
||||
? (model.revenue_sats / totalRevenue) * 100
|
||||
: 0;
|
||||
return (
|
||||
<TableRow key={model.model}>
|
||||
<TableCell className="font-medium">{model.model}</TableCell>
|
||||
<TableCell className="text-right font-mono">{model.requests}</TableCell>
|
||||
<TableCell className="text-right text-green-600 font-mono">{model.successful}</TableCell>
|
||||
<TableCell className="text-right text-red-600 font-mono">{model.failed}</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
<TableCell className='font-medium'>{model.model}</TableCell>
|
||||
<TableCell className='text-right font-mono'>
|
||||
{model.requests}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono text-green-600'>
|
||||
{model.successful}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono text-red-600'>
|
||||
{model.failed}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono'>
|
||||
{formatAmount(model.revenue_sats)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={share} className="h-2" />
|
||||
<span className="text-xs text-muted-foreground w-8 text-right">{share.toFixed(0)}%</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Progress value={share} className='h-2' />
|
||||
<span className='text-muted-foreground w-8 text-right text-xs'>
|
||||
{share.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-red-500 font-mono">
|
||||
<TableCell className='text-right font-mono text-red-500'>
|
||||
{formatAmount(model.refunds_sats)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-semibold font-mono">
|
||||
<TableCell className='text-right font-mono font-semibold'>
|
||||
{formatAmount(model.net_revenue_sats)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground font-mono">
|
||||
<TableCell className='text-muted-foreground text-right font-mono'>
|
||||
{formatAmount(model.avg_revenue_per_request)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { AlertCircle, Save, RefreshCw, Eye, EyeOff } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
interface SettingsData {
|
||||
name?: string;
|
||||
@@ -28,9 +29,32 @@ interface SettingsData {
|
||||
http_url?: string;
|
||||
onion_url?: string;
|
||||
cashu_mints?: string[];
|
||||
relays?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const HANDLED_KEYS = [
|
||||
'name',
|
||||
'description',
|
||||
'http_url',
|
||||
'onion_url',
|
||||
'npub',
|
||||
'nsec',
|
||||
'cashu_mints',
|
||||
'relays',
|
||||
'admin_password',
|
||||
'id',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
const IGNORED_KEYS = [
|
||||
'upstream_base_url',
|
||||
'upstream_api_key',
|
||||
'upstream_provider_fee',
|
||||
'exchange_fee',
|
||||
'models_path',
|
||||
];
|
||||
|
||||
interface PasswordData {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
@@ -44,6 +68,7 @@ export function AdminSettings() {
|
||||
const [error, setError] = useState<string>('');
|
||||
const [showSecrets, setShowSecrets] = useState(false);
|
||||
const [newMint, setNewMint] = useState('');
|
||||
const [newRelay, setNewRelay] = useState('');
|
||||
const [passwordData, setPasswordData] = useState<PasswordData>({
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
@@ -129,7 +154,7 @@ export function AdminSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: string, value: string | boolean) => {
|
||||
const handleInputChange = (field: string, value: unknown) => {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
@@ -153,6 +178,23 @@ export function AdminSettings() {
|
||||
}));
|
||||
};
|
||||
|
||||
const addRelay = () => {
|
||||
if (newRelay.trim()) {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
relays: [...(prev.relays || []), newRelay.trim()],
|
||||
}));
|
||||
setNewRelay('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeRelay = (index: number) => {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
relays: prev.relays?.filter((_, i) => i !== index) || [],
|
||||
}));
|
||||
};
|
||||
|
||||
const renderSecretField = (
|
||||
field: string,
|
||||
label: string,
|
||||
@@ -162,7 +204,7 @@ export function AdminSettings() {
|
||||
const displayValue = showSecrets ? value : value ? '••••••••' : '';
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<div key={field} className='space-y-2'>
|
||||
<Label htmlFor={field}>{label}</Label>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
@@ -190,6 +232,89 @@ export function AdminSettings() {
|
||||
);
|
||||
};
|
||||
|
||||
const renderDynamicField = (key: string, value: unknown) => {
|
||||
const label = key
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className='flex items-center justify-between space-y-0 py-4'
|
||||
>
|
||||
<Label htmlFor={key}>{label}</Label>
|
||||
<Switch
|
||||
id={key}
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => handleInputChange(key, checked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return (
|
||||
<div key={key} className='space-y-2'>
|
||||
<Label htmlFor={key}>{label}</Label>
|
||||
<Input
|
||||
id={key}
|
||||
type='number'
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === '' ? 0 : Number(e.target.value);
|
||||
handleInputChange(key, val);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const strValue = value.join(', ');
|
||||
return (
|
||||
<div key={key} className='space-y-2'>
|
||||
<Label htmlFor={key}>{label}</Label>
|
||||
<Textarea
|
||||
id={key}
|
||||
value={strValue}
|
||||
onChange={(e) => {
|
||||
const arr = e.target.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '');
|
||||
handleInputChange(key, arr);
|
||||
}}
|
||||
placeholder='Comma separated values'
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSecret =
|
||||
key.includes('key') ||
|
||||
key.includes('password') ||
|
||||
key.includes('secret') ||
|
||||
key.includes('nsec');
|
||||
|
||||
if (isSecret) {
|
||||
return renderSecretField(key, label);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} className='space-y-2'>
|
||||
<Label htmlFor={key}>{label}</Label>
|
||||
<Input
|
||||
id={key}
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => handleInputChange(key, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
@@ -337,6 +462,73 @@ export function AdminSettings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Relays */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Nostr Relays</CardTitle>
|
||||
<CardDescription>
|
||||
Configure Nostr relays for communication
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='newRelay'>Add Relay URL</Label>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='newRelay'
|
||||
value={newRelay}
|
||||
onChange={(e) => setNewRelay(e.target.value)}
|
||||
placeholder='wss://relay.example.com'
|
||||
/>
|
||||
<Button onClick={addRelay} disabled={!newRelay.trim()}>
|
||||
Add Relay
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings.relays && settings.relays.length > 0 && (
|
||||
<div className='space-y-2'>
|
||||
<Label>Configured Relays</Label>
|
||||
<div className='space-y-2'>
|
||||
{settings.relays.map((relay, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='flex items-center gap-2 rounded border p-2'
|
||||
>
|
||||
<span className='flex-1 text-sm'>{relay}</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => removeRelay(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Other Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Advanced Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Configure additional node settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{Object.keys(settings)
|
||||
.filter(
|
||||
(key) =>
|
||||
!HANDLED_KEYS.includes(key) && !IGNORED_KEYS.includes(key)
|
||||
)
|
||||
.map((key) => renderDynamicField(key, settings[key]))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className='mt-6'>
|
||||
<CardFooter className='flex justify-between'>
|
||||
<Button
|
||||
|
||||
@@ -60,7 +60,11 @@ export function TemporaryBalances({
|
||||
let totalRequests = 0;
|
||||
|
||||
balances.forEach((balance) => {
|
||||
totalBalance += balance.balance || 0;
|
||||
// Only count parents for total balance to avoid double counting
|
||||
// since child keys use parent balance
|
||||
if (!balance.parent_key_hash) {
|
||||
totalBalance += balance.balance || 0;
|
||||
}
|
||||
totalSpent += balance.total_spent || 0;
|
||||
totalRequests += balance.total_requests || 0;
|
||||
});
|
||||
@@ -72,6 +76,34 @@ export function TemporaryBalances({
|
||||
? calculateTotals(data)
|
||||
: { totalBalance: 0, totalSpent: 0, totalRequests: 0 };
|
||||
|
||||
// Group parents and children
|
||||
const hierarchicalData = (() => {
|
||||
if (!data) return [];
|
||||
|
||||
const parents = filteredData.filter((item) => !item.parent_key_hash);
|
||||
const result: (TemporaryBalance & { isChild?: boolean })[] = [];
|
||||
|
||||
parents.forEach((parent) => {
|
||||
result.push(parent);
|
||||
const children = data.filter(
|
||||
(item) => item.parent_key_hash === parent.hashed_key
|
||||
);
|
||||
children.forEach((child) => {
|
||||
result.push({ ...child, isChild: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Add children whose parents didn't match the search or aren't in the list
|
||||
const orphans = filteredData.filter(
|
||||
(item) =>
|
||||
item.parent_key_hash &&
|
||||
!result.some((r) => r.hashed_key === item.hashed_key)
|
||||
);
|
||||
result.push(...orphans.map((o) => ({ ...o, isChild: true })));
|
||||
|
||||
return result;
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className='h-full w-full shadow-sm'>
|
||||
@@ -182,22 +214,37 @@ export function TemporaryBalances({
|
||||
<div className='text-right'>Expiry Time</div>
|
||||
</div>
|
||||
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((balance, index) => (
|
||||
{hierarchicalData.length > 0 ? (
|
||||
hierarchicalData.map((balance, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'hover:bg-muted/50 border-t p-3 text-sm transition-colors',
|
||||
balance.balance === 0 && 'opacity-60'
|
||||
balance.balance === 0 &&
|
||||
!balance.isChild &&
|
||||
'opacity-60',
|
||||
balance.isChild &&
|
||||
'ml-4 border-l-2 border-l-blue-200 bg-blue-50/30'
|
||||
)}
|
||||
>
|
||||
{/* Desktop Layout */}
|
||||
<div className='hidden grid-cols-6 gap-2 md:grid'>
|
||||
<div className='max-w-32 truncate font-mono text-xs break-all'>
|
||||
<div className='flex max-w-48 items-center gap-2 truncate font-mono text-xs break-all'>
|
||||
{balance.isChild && (
|
||||
<span className='rounded bg-blue-100 px-1 py-0.5 text-[10px] font-bold text-blue-700 uppercase'>
|
||||
Child
|
||||
</span>
|
||||
)}
|
||||
{balance.hashed_key}
|
||||
</div>
|
||||
<div className='text-right font-mono'>
|
||||
{formatBalance(balance.balance)}
|
||||
{balance.isChild ? (
|
||||
<span className='text-muted-foreground italic'>
|
||||
(Parent)
|
||||
</span>
|
||||
) : (
|
||||
formatBalance(balance.balance)
|
||||
)}
|
||||
</div>
|
||||
<div className='text-right font-mono'>
|
||||
{formatBalance(balance.total_spent)}
|
||||
@@ -226,13 +273,20 @@ export function TemporaryBalances({
|
||||
|
||||
{/* Mobile Layout */}
|
||||
<div className='space-y-3 md:hidden'>
|
||||
<div className='space-y-1'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
Key
|
||||
</span>
|
||||
<div className='font-mono text-xs break-all'>
|
||||
{balance.hashed_key}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{balance.isChild ? 'Child Key' : 'Key'}
|
||||
</span>
|
||||
<div className='font-mono text-xs break-all'>
|
||||
{balance.hashed_key}
|
||||
</div>
|
||||
</div>
|
||||
{balance.isChild && (
|
||||
<span className='rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-bold text-blue-700 uppercase'>
|
||||
Child
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
@@ -241,7 +295,13 @@ export function TemporaryBalances({
|
||||
Balance
|
||||
</div>
|
||||
<div className='truncate font-mono text-sm'>
|
||||
{formatBalance(balance.balance)}
|
||||
{balance.isChild ? (
|
||||
<span className='text-muted-foreground text-xs italic'>
|
||||
(Uses Parent)
|
||||
</span>
|
||||
) : (
|
||||
formatBalance(balance.balance)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
|
||||
@@ -90,7 +90,11 @@ export function UsageMetricsChart({
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
}}
|
||||
itemStyle={{ fontSize: '12px' }}
|
||||
labelStyle={{ fontSize: '12px', color: 'hsl(var(--muted-foreground))', marginBottom: '8px' }}
|
||||
labelStyle={{
|
||||
fontSize: '12px',
|
||||
color: 'hsl(var(--muted-foreground))',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '12px', paddingTop: '16px' }} />
|
||||
{dataKeys.map((dataKey) => (
|
||||
|
||||
@@ -114,15 +114,19 @@ export function UsageSummaryCards({ summary }: UsageSummaryCardsProps) {
|
||||
return (
|
||||
<div className='grid gap-6 md:grid-cols-2 lg:grid-cols-4'>
|
||||
{cards.map((card) => (
|
||||
<Card key={card.title} className="hover:bg-muted/50 transition-colors">
|
||||
<Card key={card.title} className='hover:bg-muted/50 transition-colors'>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-sm font-medium text-muted-foreground'>{card.title}</CardTitle>
|
||||
<div className={`p-2 rounded-full bg-secondary`}>
|
||||
<card.icon className={`h-4 w-4 ${card.color}`} />
|
||||
<CardTitle className='text-muted-foreground text-sm font-medium'>
|
||||
{card.title}
|
||||
</CardTitle>
|
||||
<div className={`bg-secondary rounded-full p-2`}>
|
||||
<card.icon className={`h-4 w-4 ${card.color}`} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold tracking-tight'>{card.value}</div>
|
||||
<div className='text-2xl font-bold tracking-tight'>
|
||||
{card.value}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -64,6 +64,11 @@ class ApiClient {
|
||||
data,
|
||||
config
|
||||
);
|
||||
console.log(`POST response from ${endpoint}:`, {
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
headers: response.headers,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.handleAuthError(error);
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ModelSchema = z.object({
|
||||
// API key type indicators
|
||||
has_own_api_key: z.boolean(),
|
||||
api_key_type: z.string(), // "individual" or "group"
|
||||
alias_ids: z.array(z.string()).nullable().optional(),
|
||||
});
|
||||
|
||||
// Schema for a model with additional provider-specific settings
|
||||
|
||||
+120
-33
@@ -7,6 +7,9 @@ export const ProviderTypeSchema = z.object({
|
||||
default_base_url: z.string(),
|
||||
fixed_base_url: z.boolean(),
|
||||
platform_url: z.string().nullable(),
|
||||
can_create_account: z.boolean(),
|
||||
can_topup: z.boolean(),
|
||||
can_show_balance: z.boolean(),
|
||||
});
|
||||
|
||||
export const UpstreamProviderSchema = z.object({
|
||||
@@ -64,7 +67,9 @@ export const AdminModelSchema = z.object({
|
||||
pricing: AdminModelPricingSchema.or(z.record(z.any())),
|
||||
per_request_limits: z.record(z.any()).nullable().optional(),
|
||||
top_provider: z.record(z.any()).nullable().optional(),
|
||||
upstream_provider_id: z.number().nullable().optional(),
|
||||
upstream_provider_id: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
canonical_slug: z.string().nullable().optional(),
|
||||
alias_ids: z.array(z.string()).nullable().optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
@@ -116,6 +121,7 @@ export interface AdminModelAsModel {
|
||||
soft_deleted?: boolean;
|
||||
has_own_api_key: boolean;
|
||||
api_key_type: string;
|
||||
alias_ids?: string[] | null;
|
||||
}
|
||||
|
||||
export interface AdminModelGroup {
|
||||
@@ -133,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;
|
||||
}
|
||||
|
||||
@@ -153,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;
|
||||
}
|
||||
|
||||
@@ -202,6 +221,7 @@ export class AdminService {
|
||||
soft_deleted: !adminModel.enabled,
|
||||
has_own_api_key: false,
|
||||
api_key_type: 'group',
|
||||
alias_ids: adminModel.alias_ids,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -284,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(
|
||||
@@ -349,8 +381,9 @@ export class AdminService {
|
||||
original: data.pricing,
|
||||
converted: payload.pricing,
|
||||
});
|
||||
const model = await apiClient.patch<AdminModel>(
|
||||
`/admin/api/upstream-providers/${providerId}/models/${encodeURIComponent(modelId)}`,
|
||||
// Use the same POST endpoint for both create and update (upsert)
|
||||
const model = await apiClient.post<AdminModel>(
|
||||
`/admin/api/upstream-providers/${providerId}/models`,
|
||||
payload
|
||||
);
|
||||
return {
|
||||
@@ -490,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,
|
||||
@@ -545,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,
|
||||
@@ -811,7 +844,9 @@ export class AdminService {
|
||||
if (search) params.append('search', search);
|
||||
params.append('limit', limit.toString());
|
||||
|
||||
return await apiClient.get<LogResponse>(`/admin/api/logs?${params.toString()}`);
|
||||
return await apiClient.get<LogResponse>(
|
||||
`/admin/api/logs?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
static async getLogDates(): Promise<{ dates: string[] }> {
|
||||
@@ -856,6 +891,57 @@ export class AdminService {
|
||||
`/admin/api/usage/revenue-by-model?hours=${hours}&limit=${limit}`
|
||||
);
|
||||
}
|
||||
|
||||
static async createProviderAccountByType(providerType: string): Promise<{
|
||||
ok: boolean;
|
||||
account_data: Record<string, unknown>;
|
||||
message: string;
|
||||
}> {
|
||||
return await apiClient.post<{
|
||||
ok: boolean;
|
||||
account_data: Record<string, unknown>;
|
||||
message: string;
|
||||
}>('/admin/api/upstream-providers/create-account', {
|
||||
provider_type: providerType,
|
||||
});
|
||||
}
|
||||
|
||||
static async initiateProviderTopup(
|
||||
providerId: number,
|
||||
amount: number
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
topup_data: Record<string, unknown>;
|
||||
message: string;
|
||||
}> {
|
||||
return await apiClient.post<{
|
||||
ok: boolean;
|
||||
topup_data: Record<string, unknown>;
|
||||
message: string;
|
||||
}>(`/admin/api/upstream-providers/${providerId}/topup`, {
|
||||
amount: amount,
|
||||
});
|
||||
}
|
||||
|
||||
static async checkTopupStatus(
|
||||
providerId: number,
|
||||
invoiceId: string
|
||||
): Promise<{ ok: boolean; paid: boolean }> {
|
||||
return await apiClient.get<{
|
||||
ok: boolean;
|
||||
paid: boolean;
|
||||
}>(`/admin/api/upstream-providers/${providerId}/topup/${invoiceId}/status`);
|
||||
}
|
||||
|
||||
static async getProviderBalance(providerId: number): Promise<{
|
||||
ok: boolean;
|
||||
balance_data: number | null | Record<string, unknown>;
|
||||
}> {
|
||||
return await apiClient.get<{
|
||||
ok: boolean;
|
||||
balance_data: number | null | Record<string, unknown>;
|
||||
}>(`/admin/api/upstream-providers/${providerId}/balance`);
|
||||
}
|
||||
}
|
||||
|
||||
export const TemporaryBalanceSchema = z.object({
|
||||
@@ -865,6 +951,7 @@ export const TemporaryBalanceSchema = z.object({
|
||||
total_requests: z.number(),
|
||||
refund_address: z.string().nullable(),
|
||||
key_expiry_time: z.number().nullable(),
|
||||
parent_key_hash: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type TemporaryBalance = z.infer<typeof TemporaryBalanceSchema>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,3 @@ export const useCurrencyStore = create<CurrencyState>()(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
Generated
-8986
File diff suppressed because it is too large
Load Diff
+12
-9
@@ -15,6 +15,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.10",
|
||||
"@radix-ui/react-avatar": "^1.1.6",
|
||||
@@ -40,18 +41,19 @@
|
||||
"@radix-ui/react-toggle": "^1.1.6",
|
||||
"@radix-ui/react-toggle-group": "^1.1.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.3",
|
||||
"@tanstack/react-query": "^5.74.4",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.501.0",
|
||||
"next": "15.3.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "15.5.9",
|
||||
"next-themes": "^0.4.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.6.7",
|
||||
@@ -66,20 +68,21 @@
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tanstack/react-query-devtools": "^5.74.4",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tanstack/react-query-devtools": "^5.91.2",
|
||||
"@types/node": "^20",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-next": "15.3.1",
|
||||
"eslint-config-next": "15.5.9",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwindcss": "^4",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+7998
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user