mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-04 17:14:38 +00:00
Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87efed0021 | ||
|
|
3dfbd3815c | ||
|
|
6199f6467b | ||
|
|
9960e5596e | ||
|
|
a516a10737 | ||
|
|
be9b1da832 | ||
|
|
e4dd0aceae | ||
|
|
30170c2ec6 | ||
|
|
825bd38d8e | ||
|
|
d3b3152520 | ||
|
|
653b51452a | ||
|
|
aa00664cd6 | ||
|
|
ea677cf66b | ||
|
|
11868f9180 | ||
|
|
daae4cd2cb | ||
|
|
2cb8d2d744 | ||
|
|
22f7d198a6 | ||
|
|
73af5e23c7 | ||
|
|
eb5af32fac | ||
|
|
0f9df3ca77 | ||
|
|
3b1b3da847 | ||
|
|
a34583d2ca | ||
|
|
4eda9eaf1b | ||
|
|
b9879cbea7 | ||
|
|
367265b9fe | ||
|
|
0b3ccb5fb0 | ||
|
|
dbd43f52fb | ||
|
|
39657ed64f | ||
|
|
493b4f0f1f | ||
|
|
ca7e8bec71 | ||
|
|
c4cc09d61e | ||
|
|
21d363f6aa | ||
|
|
5e21f6ccbc | ||
|
|
b7603dcf69 | ||
|
|
7dccfa745f | ||
|
|
54d5118980 | ||
|
|
7723ab4a95 | ||
|
|
86c022d8db | ||
|
|
3a939d0dd1 | ||
|
|
d192a6a6b4 | ||
|
|
57bf1b68d9 | ||
|
|
00d0415518 | ||
|
|
e8585b276f | ||
|
|
4b5e911435 | ||
|
|
761aabfec3 | ||
|
|
f0c45a7ce4 | ||
|
|
fc8ccf63ba | ||
|
|
eeb70e4ee5 | ||
|
|
a3b410b467 | ||
|
|
bdf0e2c192 | ||
|
|
334453f934 | ||
|
|
5a4ba60072 | ||
|
|
eed5bc5b04 | ||
|
|
f4b014cb05 | ||
|
|
4418d87664 | ||
|
|
41fd2e2dfc | ||
|
|
2c404c66d6 | ||
|
|
0fa3e77f9a | ||
|
|
d41c214d9e | ||
|
|
ec0fcfb48b | ||
|
|
82d2627c60 |
@@ -59,25 +59,30 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
cache: "npm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: ui/package-lock.json
|
cache-dependency-path: ui/pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Install UI dependencies
|
- name: Install UI dependencies
|
||||||
working-directory: ./ui
|
working-directory: ./ui
|
||||||
run: npm ci
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Run UI format check
|
- name: Run UI format check
|
||||||
working-directory: ./ui
|
working-directory: ./ui
|
||||||
run: npm run format-check
|
run: pnpm run format-check
|
||||||
|
|
||||||
- name: Run UI linting
|
- name: Run UI linting
|
||||||
working-directory: ./ui
|
working-directory: ./ui
|
||||||
run: npm run lint
|
run: pnpm run lint
|
||||||
|
|
||||||
- name: Run UI build
|
- name: Run UI build
|
||||||
working-directory: ./ui
|
working-directory: ./ui
|
||||||
run: npm run build
|
run: pnpm run build
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
# Reproducing Cursor Problems
|
|
||||||
|
|
||||||
Each subdirectory contains a `request.json` (the request body) and `response.json` (the error response received).
|
|
||||||
|
|
||||||
## Using curl to reproduce
|
|
||||||
|
|
||||||
From the `routstr-core/` directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# OpenAI model error
|
|
||||||
curl -X POST https://staging.routstr.com/v1/chat/completions \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer $API_KEY" \
|
|
||||||
-d @cursor-problems/openai-model-error/request.json
|
|
||||||
|
|
||||||
# Anthropic internal error
|
|
||||||
curl -X POST https://staging.routstr.com/v1/chat/completions \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer $API_KEY" \
|
|
||||||
-d @cursor-problems/anthropic-internal-error/request.json
|
|
||||||
|
|
||||||
# Model not found error
|
|
||||||
curl -X POST https://staging.routstr.com/v1/chat/completions \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer $API_KEY" \
|
|
||||||
-d @cursor-problems/model-not-found-error/request.json
|
|
||||||
|
|
||||||
# Upstream rate limit error
|
|
||||||
curl -X POST https://staging.routstr.com/v1/chat/completions \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer $API_KEY" \
|
|
||||||
-d @cursor-problems/upstream-rate-limit-error/request.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Generic pattern
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST <API_ENDPOINT> \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer $API_KEY" \
|
|
||||||
-d @cursor-problems/<directory>/request.json
|
|
||||||
```
|
|
||||||
|
|
||||||
The `-d @filename` syntax tells curl to read the request body from a file.
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"error": {
|
|
||||||
"message": "Internal Server Error",
|
|
||||||
"type": "upstream_error",
|
|
||||||
"code": 502
|
|
||||||
},
|
|
||||||
"request_id": "4a04e4f8-4a31-45f1-8189-455c86fc4e89"
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"error": {
|
|
||||||
"message": "Model 'claude-4.5-sonnet-thinking' not found",
|
|
||||||
"type": "invalid_model",
|
|
||||||
"code": 400
|
|
||||||
},
|
|
||||||
"request_id": "d410f512-3221-4047-a4ee-9be6e3fabe38"
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"error": {
|
|
||||||
"message": "Input required: specify \"prompt\" or \"messages\"",
|
|
||||||
"type": "invalid_request_error",
|
|
||||||
"code": 400
|
|
||||||
},
|
|
||||||
"request_id": "586e0aec-351f-413a-8641-ddda4a0cbadf"
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"error": {
|
|
||||||
"message": "Upstream request failed",
|
|
||||||
"type": "rate_limit_exceeded",
|
|
||||||
"code": 429
|
|
||||||
},
|
|
||||||
"request_id": "80657fc6-4bca-4cb1-945d-ea65ec8a53c4"
|
|
||||||
}
|
|
||||||
-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,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
|
||||||
|
)
|
||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "routstr"
|
name = "routstr"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@@ -73,6 +73,7 @@ packages = ["routstr"]
|
|||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I"]
|
select = ["E", "F", "I"]
|
||||||
ignore = ["E501"]
|
ignore = ["E501"]
|
||||||
|
exclude = ["examples"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.11"
|
python_version = "3.11"
|
||||||
|
|||||||
+50
-20
@@ -441,6 +441,29 @@ async def adjust_payment_for_tokens(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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) == 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] + "...",
|
||||||
|
"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] + "..."},
|
||||||
|
)
|
||||||
|
|
||||||
match await calculate_cost(response_data, deducted_max_cost, session):
|
match await calculate_cost(response_data, deducted_max_cost, session):
|
||||||
case MaxCostData() as cost:
|
case MaxCostData() as cost:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -465,7 +488,7 @@ async def adjust_payment_for_tokens(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to finalize max-cost payment - insufficient reserved balance",
|
"Failed to finalize max-cost payment - retrying reservation release",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
"deducted_max_cost": deducted_max_cost,
|
"deducted_max_cost": deducted_max_cost,
|
||||||
@@ -474,6 +497,7 @@ async def adjust_payment_for_tokens(
|
|||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await release_reservation_only()
|
||||||
else:
|
else:
|
||||||
await session.refresh(key)
|
await session.refresh(key)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -568,13 +592,14 @@ async def adjust_payment_for_tokens(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to finalize additional charge (concurrent operation)",
|
"Failed to finalize additional charge - releasing reservation",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
"attempted_charge": total_cost_msats,
|
"attempted_charge": total_cost_msats,
|
||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await release_reservation_only()
|
||||||
else:
|
else:
|
||||||
# Refund some of the base cost
|
# Refund some of the base cost
|
||||||
refund = abs(cost_difference)
|
refund = abs(cost_difference)
|
||||||
@@ -603,7 +628,7 @@ async def adjust_payment_for_tokens(
|
|||||||
|
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to finalize payment - insufficient reserved balance",
|
"Failed to finalize payment - releasing reservation",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
"deducted_max_cost": deducted_max_cost,
|
"deducted_max_cost": deducted_max_cost,
|
||||||
@@ -612,28 +637,27 @@ async def adjust_payment_for_tokens(
|
|||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Still return the cost data even if we couldn't properly finalize
|
await release_reservation_only()
|
||||||
# The reservation was already made, so the user has paid
|
else:
|
||||||
|
cost.total_msats = total_cost_msats
|
||||||
|
await session.refresh(key)
|
||||||
|
|
||||||
cost.total_msats = total_cost_msats
|
logger.info(
|
||||||
await session.refresh(key)
|
"Refund processed successfully",
|
||||||
|
extra={
|
||||||
logger.info(
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
"Refund processed successfully",
|
"refunded_amount": refund,
|
||||||
extra={
|
"new_balance": key.balance,
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"final_cost": cost.total_msats,
|
||||||
"refunded_amount": refund,
|
"model": model,
|
||||||
"new_balance": key.balance,
|
},
|
||||||
"final_cost": cost.total_msats,
|
)
|
||||||
"model": model,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return cost.dict()
|
return cost.dict()
|
||||||
|
|
||||||
case CostDataError() as error:
|
case CostDataError() as error:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Cost calculation error during payment adjustment",
|
"Cost calculation error during payment adjustment - releasing reservation",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
"model": model,
|
"model": model,
|
||||||
@@ -641,6 +665,7 @@ async def adjust_payment_for_tokens(
|
|||||||
"error_code": error.code,
|
"error_code": error.code,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await release_reservation_only()
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
@@ -652,7 +677,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 {
|
return {
|
||||||
"base_msats": deducted_max_cost,
|
"base_msats": deducted_max_cost,
|
||||||
"input_msats": 0,
|
"input_msats": 0,
|
||||||
|
|||||||
+2
-1
@@ -154,7 +154,8 @@ async def refund_wallet_endpoint(
|
|||||||
return cached
|
return cached
|
||||||
|
|
||||||
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
||||||
remaining_balance_msats: int = key.balance
|
|
||||||
|
remaining_balance_msats: int = key.total_balance
|
||||||
|
|
||||||
if key.refund_currency == "sat":
|
if key.refund_currency == "sat":
|
||||||
remaining_balance = remaining_balance_msats // 1000
|
remaining_balance = remaining_balance_msats // 1000
|
||||||
|
|||||||
@@ -3080,6 +3080,9 @@ async def get_logs_api(
|
|||||||
level: str | None = None,
|
level: str | None = None,
|
||||||
request_id: str | None = None,
|
request_id: str | None = None,
|
||||||
search: 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,
|
limit: int = 100,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""
|
"""
|
||||||
@@ -3090,16 +3093,32 @@ async def get_logs_api(
|
|||||||
level: Filter by log level
|
level: Filter by log level
|
||||||
request_id: Filter by request ID
|
request_id: Filter by request ID
|
||||||
search: Search text in message and name fields (case-insensitive)
|
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
|
limit: Maximum number of entries to return
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing logs and filter metadata
|
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(
|
log_entries = log_manager.search_logs(
|
||||||
date=date,
|
date=date,
|
||||||
level=level,
|
level=level,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
search_text=search,
|
search_text=search,
|
||||||
|
status_codes=status_code_list,
|
||||||
|
methods=method_list,
|
||||||
|
endpoints=endpoint_list,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3110,6 +3129,9 @@ async def get_logs_api(
|
|||||||
"level": level,
|
"level": level,
|
||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
"search": search,
|
"search": search,
|
||||||
|
"status_codes": status_codes,
|
||||||
|
"methods": methods,
|
||||||
|
"endpoints": endpoints,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -6,7 +6,7 @@ from typing import AsyncGenerator
|
|||||||
from alembic import command
|
from alembic import command
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
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 sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from .logging import get_logger
|
from .logging import get_logger
|
||||||
@@ -53,6 +53,14 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
|||||||
return self.balance - self.reserved_balance
|
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
|
class ModelRow(SQLModel, table=True): # type: ignore
|
||||||
__tablename__ = "models"
|
__tablename__ = "models"
|
||||||
id: str = Field(primary_key=True)
|
id: str = Field(primary_key=True)
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ class LogManager:
|
|||||||
level: str | None = None,
|
level: str | None = None,
|
||||||
request_id: str | None = None,
|
request_id: str | None = None,
|
||||||
search_text: 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,
|
limit: int = 100,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -134,7 +137,13 @@ class LogManager:
|
|||||||
|
|
||||||
for log_data in iterator:
|
for log_data in iterator:
|
||||||
if not self._matches_filters(
|
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
|
continue
|
||||||
|
|
||||||
@@ -153,6 +162,9 @@ class LogManager:
|
|||||||
level: str | None,
|
level: str | None,
|
||||||
request_id: str | None,
|
request_id: str | None,
|
||||||
search_text_lower: str | None,
|
search_text_lower: str | None,
|
||||||
|
status_codes: list[int] | None = None,
|
||||||
|
methods: list[str] | None = None,
|
||||||
|
endpoints: list[str] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if level and log_data.get("levelname", "").upper() != level.upper():
|
if level and log_data.get("levelname", "").upper() != level.upper():
|
||||||
return False
|
return False
|
||||||
@@ -160,6 +172,36 @@ class LogManager:
|
|||||||
if request_id and log_data.get("request_id") != request_id:
|
if request_id and log_data.get("request_id") != request_id:
|
||||||
return False
|
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:
|
if search_text_lower:
|
||||||
message = str(log_data.get("message", "")).lower()
|
message = str(log_data.get("message", "")).lower()
|
||||||
name = str(log_data.get("name", "")).lower()
|
name = str(log_data.get("name", "")).lower()
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ setup_logging()
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
if os.getenv("VERSION_SUFFIX") is not None:
|
if os.getenv("VERSION_SUFFIX") is not None:
|
||||||
__version__ = f"0.2.1-{os.getenv('VERSION_SUFFIX')}"
|
__version__ = f"0.2.2-{os.getenv('VERSION_SUFFIX')}"
|
||||||
else:
|
else:
|
||||||
__version__ = "0.2.1"
|
__version__ = "0.2.2"
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -61,6 +61,10 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
# Initialize application settings (env -> computed -> DB precedence)
|
# Initialize application settings (env -> computed -> DB precedence)
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
s = await SettingsService.initialize(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)
|
||||||
|
|
||||||
# Apply app metadata from settings
|
# Apply app metadata from settings
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import os
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic.v1 import BaseModel, BaseSettings, Field
|
from pydantic.v1 import BaseModel, BaseSettings, Field, validator
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
@@ -37,6 +37,13 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Cashu
|
# Cashu
|
||||||
cashu_mints: list[str] = Field(default_factory=list, env="CASHU_MINTS")
|
cashu_mints: list[str] = Field(default_factory=list, env="CASHU_MINTS")
|
||||||
|
|
||||||
|
@validator("cashu_mints", pre=True, each_item=True)
|
||||||
|
def normalize_mint_url(cls, v: str) -> str:
|
||||||
|
if isinstance(v, str):
|
||||||
|
return v.rstrip("/")
|
||||||
|
return v
|
||||||
|
|
||||||
receive_ln_address: str = Field(default="", env="RECEIVE_LN_ADDRESS")
|
receive_ln_address: str = Field(default="", env="RECEIVE_LN_ADDRESS")
|
||||||
primary_mint: str = Field(default="", env="PRIMARY_MINT_URL")
|
primary_mint: str = Field(default="", env="PRIMARY_MINT_URL")
|
||||||
primary_mint_unit: str = Field(default="sat", env="PRIMARY_MINT_UNIT")
|
primary_mint_unit: str = Field(default="sat", env="PRIMARY_MINT_UNIT")
|
||||||
@@ -54,6 +61,9 @@ class Settings(BaseSettings):
|
|||||||
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
|
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
|
||||||
# Minimum per-request charge in millisatoshis when model pricing is free/zero
|
# Minimum per-request charge in millisatoshis when model pricing is free/zero
|
||||||
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
|
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
|
# Network
|
||||||
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
|
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
|
||||||
|
|||||||
@@ -48,13 +48,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:
|
if "usage" not in response_data or response_data["usage"] is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"No usage data in response, using base cost only",
|
"No usage data in response, using base cost only",
|
||||||
@@ -63,7 +56,12 @@ async def calculate_cost( # todo: can be sync
|
|||||||
"model": response_data.get("model", "unknown"),
|
"model": response_data.get("model", "unknown"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return cost_data
|
return MaxCostData(
|
||||||
|
base_msats=0,
|
||||||
|
input_msats=0,
|
||||||
|
output_msats=0,
|
||||||
|
total_msats=0,
|
||||||
|
)
|
||||||
|
|
||||||
usage_data = response_data["usage"]
|
usage_data = response_data["usage"]
|
||||||
|
|
||||||
@@ -178,7 +176,12 @@ async def calculate_cost( # todo: can be sync
|
|||||||
"model": response_data.get("model", "unknown"),
|
"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 = usage_data.get("prompt_tokens", 0)
|
input_tokens = usage_data.get("prompt_tokens", 0)
|
||||||
output_tokens = usage_data.get("completion_tokens", 0)
|
output_tokens = usage_data.get("completion_tokens", 0)
|
||||||
@@ -191,6 +194,18 @@ async def calculate_cost( # todo: can be sync
|
|||||||
output_tokens if output_tokens != 0 else usage_data.get("output_tokens", 0)
|
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)
|
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
|
||||||
|
|
||||||
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ async def raw_send_to_lnurl(
|
|||||||
f"({min_sendable_sat} - {max_sendable_sat} {unit})"
|
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
|
estimated_fees_msat = estimated_fees_sat * 1000
|
||||||
final_amount = amount_msat - estimated_fees_msat
|
final_amount = amount_msat - estimated_fees_msat
|
||||||
|
|
||||||
|
|||||||
+73
-28
@@ -16,6 +16,7 @@ from .core.db import (
|
|||||||
create_session,
|
create_session,
|
||||||
get_session,
|
get_session,
|
||||||
)
|
)
|
||||||
|
from .core.settings import settings
|
||||||
from .payment.helpers import (
|
from .payment.helpers import (
|
||||||
calculate_discounted_max_cost,
|
calculate_discounted_max_cost,
|
||||||
check_token_balance,
|
check_token_balance,
|
||||||
@@ -25,6 +26,7 @@ from .payment.helpers import (
|
|||||||
from .payment.models import Model
|
from .payment.models import Model
|
||||||
from .upstream import BaseUpstreamProvider
|
from .upstream import BaseUpstreamProvider
|
||||||
from .upstream.helpers import init_upstreams
|
from .upstream.helpers import init_upstreams
|
||||||
|
from .wallet import deserialize_token_from_string
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
proxy_router = APIRouter()
|
proxy_router = APIRouter()
|
||||||
@@ -137,20 +139,32 @@ async def proxy(
|
|||||||
"unauthorized", "Unauthorized", 401, request=request
|
"unauthorized", "Unauthorized", 401, request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info( # TODO: move to middleware, async
|
is_responses_api = path.startswith("v1/responses") or path.startswith("responses")
|
||||||
"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],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
request_body = await request.body()
|
request_body = await request.body()
|
||||||
request_body_dict = parse_request_body_json(request_body, path)
|
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")
|
||||||
|
|
||||||
|
if "https://testnut.cashu.space" in settings.cashu_mints:
|
||||||
|
try:
|
||||||
|
token_str = None
|
||||||
|
if x_cashu_header := headers.get("x-cashu"):
|
||||||
|
token_str = x_cashu_header
|
||||||
|
elif auth_header := headers.get("authorization"):
|
||||||
|
parts = auth_header.split(" ")
|
||||||
|
if len(parts) > 1 and not parts[1].startswith("sk-"):
|
||||||
|
token_str = parts[1]
|
||||||
|
|
||||||
|
if token_str:
|
||||||
|
token_obj = deserialize_token_from_string(token_str)
|
||||||
|
if token_obj.mint == "https://testnut.cashu.space":
|
||||||
|
model_id = "mock/gpt-420-mock"
|
||||||
|
request_body_dict["model"] = model_id
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
model_obj = get_model_instance(model_id)
|
model_obj = get_model_instance(model_id)
|
||||||
if not model_obj:
|
if not model_obj:
|
||||||
@@ -176,9 +190,14 @@ async def proxy(
|
|||||||
check_token_balance(headers, request_body_dict, max_cost_for_model)
|
check_token_balance(headers, request_body_dict, max_cost_for_model)
|
||||||
|
|
||||||
if x_cashu := headers.get("x-cashu", None):
|
if x_cashu := headers.get("x-cashu", None):
|
||||||
return await upstream.handle_x_cashu(
|
if is_responses_api:
|
||||||
request, x_cashu, path, max_cost_for_model, model_obj
|
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
|
||||||
|
)
|
||||||
|
|
||||||
elif auth := headers.get("authorization", None):
|
elif auth := headers.get("authorization", None):
|
||||||
key = await get_bearer_token_key(headers, path, session, auth)
|
key = await get_bearer_token_key(headers, path, session, auth)
|
||||||
@@ -193,28 +212,36 @@ async def proxy(
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.debug("Processing unauthenticated GET request", extra={"path": path})
|
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))
|
headers = upstream.prepare_headers(dict(request.headers))
|
||||||
return await upstream.forward_get_request(request, path, headers)
|
return await upstream.forward_get_request(request, path, headers)
|
||||||
|
|
||||||
# Only pay for request if we have request body data (for completions endpoints)
|
|
||||||
if request_body_dict:
|
if request_body_dict:
|
||||||
await pay_for_request(key, max_cost_for_model, session)
|
await pay_for_request(key, max_cost_for_model, session)
|
||||||
|
|
||||||
# Prepare headers for upstream
|
|
||||||
headers = upstream.prepare_headers(dict(request.headers))
|
headers = upstream.prepare_headers(dict(request.headers))
|
||||||
|
|
||||||
# Forward to upstream and handle response
|
if is_responses_api:
|
||||||
response = await upstream.forward_request(
|
response = await upstream.forward_responses_request(
|
||||||
request,
|
request,
|
||||||
path,
|
path,
|
||||||
headers,
|
headers,
|
||||||
request_body,
|
request_body,
|
||||||
key,
|
key,
|
||||||
max_cost_for_model,
|
max_cost_for_model,
|
||||||
session,
|
session,
|
||||||
model_obj,
|
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:
|
if response.status_code != 200:
|
||||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||||
@@ -317,6 +344,24 @@ async def get_bearer_token_key(
|
|||||||
raise
|
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]:
|
def parse_request_body_json(request_body: bytes, path: str) -> dict[str, Any]:
|
||||||
request_body_dict = {}
|
request_body_dict = {}
|
||||||
if request_body:
|
if request_body:
|
||||||
|
|||||||
+1433
-2
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,265 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
from typing import AsyncIterator
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import Response, StreamingResponse
|
||||||
|
|
||||||
|
from ..core.db import ApiKey, AsyncSession
|
||||||
|
from ..payment.models import Architecture, Model, Pricing
|
||||||
|
from .base import BaseUpstreamProvider
|
||||||
|
|
||||||
|
|
||||||
|
class MockUpstreamProvider(BaseUpstreamProvider):
|
||||||
|
"""Fack Mock Upstream provider specifically for Testing."""
|
||||||
|
|
||||||
|
provider_type = "mock"
|
||||||
|
|
||||||
|
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:
|
||||||
|
if path.endswith("chat/completions"):
|
||||||
|
is_streaming = False
|
||||||
|
if request_body:
|
||||||
|
request_data = json.loads(request_body)
|
||||||
|
is_streaming = request_data.get("stream", False)
|
||||||
|
|
||||||
|
if is_streaming:
|
||||||
|
|
||||||
|
async def fake_streaming_response(
|
||||||
|
chunk_size: int | None = None,
|
||||||
|
) -> AsyncIterator[bytes]:
|
||||||
|
suffix = random.randint(1000, 9999)
|
||||||
|
req_id = f"gen-mock-stream-{suffix}"
|
||||||
|
created = 1766138895
|
||||||
|
model = "mock/gpt-420-mock"
|
||||||
|
|
||||||
|
def make_chunk(
|
||||||
|
delta: dict,
|
||||||
|
finish_reason: str | None = None,
|
||||||
|
usage: dict | None = None,
|
||||||
|
) -> bytes:
|
||||||
|
chunk = {
|
||||||
|
"id": req_id,
|
||||||
|
"provider": "MockProvider",
|
||||||
|
"model": model,
|
||||||
|
"object": "chat.completion.chunk",
|
||||||
|
"created": created,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"delta": delta,
|
||||||
|
"finish_reason": finish_reason,
|
||||||
|
"native_finish_reason": "completed"
|
||||||
|
if finish_reason
|
||||||
|
else None,
|
||||||
|
"logprobs": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if usage:
|
||||||
|
chunk["usage"] = usage
|
||||||
|
return f"data: {json.dumps(chunk)}\n\n".encode()
|
||||||
|
|
||||||
|
# 1. Initial chunk
|
||||||
|
yield make_chunk({"role": "assistant", "content": ""})
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
|
||||||
|
# 2. Reasoning chunks
|
||||||
|
reasoning_tokens = ["Mock", " reason", "ing", "..."]
|
||||||
|
for token in reasoning_tokens:
|
||||||
|
delta = {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"reasoning": token,
|
||||||
|
"reasoning_details": [
|
||||||
|
{
|
||||||
|
"type": "reasoning.summary",
|
||||||
|
"summary": token,
|
||||||
|
"format": "openai-responses-v1",
|
||||||
|
"index": 0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
yield make_chunk(delta)
|
||||||
|
await asyncio.sleep(0.03)
|
||||||
|
|
||||||
|
# 3. Content chunks
|
||||||
|
content_tokens = ["This", " is", " a", " mock", " stream", "."]
|
||||||
|
for token in content_tokens:
|
||||||
|
yield make_chunk({"role": "assistant", "content": token})
|
||||||
|
await asyncio.sleep(0.03)
|
||||||
|
|
||||||
|
# 4. Finish chunk
|
||||||
|
yield make_chunk(
|
||||||
|
{"role": "assistant", "content": ""}, finish_reason="stop"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Usage chunk
|
||||||
|
usage_data = {
|
||||||
|
"prompt_tokens": 10,
|
||||||
|
"completion_tokens": 20,
|
||||||
|
"total_tokens": 30,
|
||||||
|
"cost": 0.001,
|
||||||
|
"is_byok": False,
|
||||||
|
"prompt_tokens_details": {
|
||||||
|
"cached_tokens": 0,
|
||||||
|
"audio_tokens": 0,
|
||||||
|
"video_tokens": 0,
|
||||||
|
},
|
||||||
|
"cost_details": {
|
||||||
|
"upstream_inference_cost": None,
|
||||||
|
"upstream_inference_prompt_cost": 0,
|
||||||
|
"upstream_inference_completions_cost": 0.001,
|
||||||
|
},
|
||||||
|
"completion_tokens_details": {
|
||||||
|
"reasoning_tokens": 10,
|
||||||
|
"image_tokens": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
usage_chunk = {
|
||||||
|
"id": req_id,
|
||||||
|
"provider": "MockProvider",
|
||||||
|
"model": model,
|
||||||
|
"object": "chat.completion.chunk",
|
||||||
|
"created": created,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {"role": "assistant", "content": ""},
|
||||||
|
"finish_reason": None,
|
||||||
|
"native_finish_reason": None,
|
||||||
|
"logprobs": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": usage_data,
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(usage_chunk)}\n\n".encode()
|
||||||
|
|
||||||
|
# 6. DONE
|
||||||
|
yield b"data: [DONE]\n\n"
|
||||||
|
|
||||||
|
# 7. Cost
|
||||||
|
cost_chunk = {
|
||||||
|
"cost": {
|
||||||
|
"base_msats": 0,
|
||||||
|
"input_msats": 2,
|
||||||
|
"output_msats": 10,
|
||||||
|
"total_msats": 12,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(cost_chunk)}\n\n".encode()
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
fake_streaming_response(),
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
suffix = random.randint(1000, 9999)
|
||||||
|
content_dict = {
|
||||||
|
"id": f"gen-mock-{suffix}",
|
||||||
|
"provider": "MockProvider",
|
||||||
|
"model": "mock/gpt-5-mini",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1766138655,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"logprobs": None,
|
||||||
|
"finish_reason": "length",
|
||||||
|
"native_finish_reason": "max_output_tokens",
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": f"Mock Content {suffix}",
|
||||||
|
"refusal": None,
|
||||||
|
"reasoning": f"Mock Reasoning {suffix}",
|
||||||
|
"reasoning_details": [
|
||||||
|
{
|
||||||
|
"format": "openai-responses-v1",
|
||||||
|
"index": 0,
|
||||||
|
"type": "reasoning.summary",
|
||||||
|
"summary": f"Mock Summary {suffix}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": f"rs_mock_{suffix}",
|
||||||
|
"format": "openai-responses-v1",
|
||||||
|
"index": 0,
|
||||||
|
"type": "reasoning.encrypted",
|
||||||
|
"data": "mock_encrypted_data",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 10,
|
||||||
|
"completion_tokens": 10,
|
||||||
|
"total_tokens": 20,
|
||||||
|
"cost": 0,
|
||||||
|
"is_byok": False,
|
||||||
|
"prompt_tokens_details": {
|
||||||
|
"cached_tokens": 0,
|
||||||
|
"audio_tokens": 0,
|
||||||
|
"video_tokens": 0,
|
||||||
|
},
|
||||||
|
"cost_details": {
|
||||||
|
"upstream_inference_cost": None,
|
||||||
|
"upstream_inference_prompt_cost": 0,
|
||||||
|
"upstream_inference_completions_cost": 0,
|
||||||
|
},
|
||||||
|
"completion_tokens_details": {
|
||||||
|
"reasoning_tokens": 5,
|
||||||
|
"image_tokens": 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"cost": {
|
||||||
|
"base_msats": 0,
|
||||||
|
"input_msats": 0,
|
||||||
|
"output_msats": 0,
|
||||||
|
"total_msats": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return Response(json.dumps(content_dict).encode(), 200)
|
||||||
|
|
||||||
|
elif path.endswith("embeddings"):
|
||||||
|
raise NotImplementedError
|
||||||
|
elif path.endswith("responses"):
|
||||||
|
raise NotImplementedError
|
||||||
|
else:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def fetch_models(self) -> list[Model]:
|
||||||
|
return [
|
||||||
|
Model(
|
||||||
|
id="mock/gpt-420-mock",
|
||||||
|
name="mock/gpt-420-mock",
|
||||||
|
created=0,
|
||||||
|
description="mock model for testing",
|
||||||
|
context_length=8192,
|
||||||
|
architecture=Architecture(
|
||||||
|
modality="text",
|
||||||
|
input_modalities=["text"],
|
||||||
|
output_modalities=["text"],
|
||||||
|
tokenizer="",
|
||||||
|
instruct_type=None,
|
||||||
|
),
|
||||||
|
pricing=Pricing(prompt=0.01, completion=0.01),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def transform_model_name(self, model_id: str) -> str:
|
||||||
|
return "fake-model"
|
||||||
|
|
||||||
|
async def get_balance(self) -> float | None:
|
||||||
|
return 420.69
|
||||||
@@ -187,11 +187,44 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def stream_with_cost() -> AsyncGenerator[bytes, None]:
|
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:
|
try:
|
||||||
async for chunk in response_generator:
|
async for chunk in response_generator:
|
||||||
sse_data = f"data: {json.dumps(chunk)}\n\n"
|
sse_data = f"data: {json.dumps(chunk)}\n\n"
|
||||||
yield sse_data.encode()
|
yield sse_data.encode()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Error in Gemini streaming response",
|
"Error in Gemini streaming response",
|
||||||
@@ -202,6 +235,9 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
if not payment_finalized:
|
||||||
|
await finalize_payment()
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
stream_with_cost(),
|
stream_with_cost(),
|
||||||
|
|||||||
@@ -218,6 +218,14 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
|||||||
results = await asyncio.gather(*tasks)
|
results = await asyncio.gather(*tasks)
|
||||||
upstreams = [p for p in results if p is not None]
|
upstreams = [p for p in results if p is not None]
|
||||||
|
|
||||||
|
if "https://testnut.cashu.space" in settings.cashu_mints:
|
||||||
|
from .fake import MockUpstreamProvider
|
||||||
|
|
||||||
|
mock_provider = MockUpstreamProvider("mock", "mock")
|
||||||
|
await mock_provider.refresh_models_cache()
|
||||||
|
upstreams.append(mock_provider)
|
||||||
|
logger.info("Initialized MockUpstreamProvider for testnut mint")
|
||||||
|
|
||||||
return upstreams
|
return upstreams
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -81,7 +81,7 @@ async def swap_to_primary_mint(
|
|||||||
amount_msat = token_amount
|
amount_msat = token_amount
|
||||||
else:
|
else:
|
||||||
raise ValueError("Invalid unit")
|
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
|
amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000
|
||||||
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
|
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
|
||||||
|
|
||||||
@@ -313,6 +313,8 @@ async def periodic_payout() -> None:
|
|||||||
try:
|
try:
|
||||||
async with db.create_session() as session:
|
async with db.create_session() as session:
|
||||||
for mint_url in settings.cashu_mints:
|
for mint_url in settings.cashu_mints:
|
||||||
|
if mint_url == "https://testnut.cashu.space":
|
||||||
|
continue
|
||||||
for unit in ["sat", "msat"]:
|
for unit in ["sat", "msat"]:
|
||||||
wallet = await get_wallet(mint_url, unit)
|
wallet = await get_wallet(mint_url, unit)
|
||||||
proofs = get_proofs_per_mint_and_unit(
|
proofs = get_proofs_per_mint_and_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
|
||||||
+8
-3
@@ -4,17 +4,22 @@ FROM base AS deps
|
|||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||||
RUN npm i
|
|
||||||
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
RUN npm run build
|
RUN pnpm run build
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -6,12 +6,14 @@ RUN apk add --no-cache libc6-compat
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package.json package-lock.json* pnpm-lock.yaml* ./
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
RUN npm ci
|
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Build the UI
|
# Build the UI
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ ENV NODE_ENV=production
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build && \
|
RUN pnpm run build && \
|
||||||
echo "UI build completed at $(date)"
|
echo "UI build completed at $(date)"
|
||||||
|
|
||||||
# Use the builder stage as the final stage
|
# Use the builder stage as the final stage
|
||||||
|
|||||||
+450
-2
@@ -21,7 +21,17 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover';
|
} from '@/components/ui/popover';
|
||||||
import { Calendar } from '@/components/ui/calendar';
|
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 { useState, useEffect } from 'react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -31,11 +41,17 @@ interface LogFiltersProps {
|
|||||||
selectedLevel: string;
|
selectedLevel: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
searchText: string;
|
searchText: string;
|
||||||
|
selectedStatusCodes: string[];
|
||||||
|
selectedMethods: string[];
|
||||||
|
selectedEndpoints: string[];
|
||||||
limit: number;
|
limit: number;
|
||||||
onDateChange: (date: string) => void;
|
onDateChange: (date: string) => void;
|
||||||
onLevelChange: (level: string) => void;
|
onLevelChange: (level: string) => void;
|
||||||
onRequestIdChange: (requestId: string) => void;
|
onRequestIdChange: (requestId: string) => void;
|
||||||
onSearchTextChange: (searchText: string) => void;
|
onSearchTextChange: (searchText: string) => void;
|
||||||
|
onStatusCodesChange: (statusCodes: string[]) => void;
|
||||||
|
onMethodsChange: (methods: string[]) => void;
|
||||||
|
onEndpointsChange: (endpoints: string[]) => void;
|
||||||
onLimitChange: (limit: number) => void;
|
onLimitChange: (limit: number) => void;
|
||||||
onClearFilters: () => void;
|
onClearFilters: () => void;
|
||||||
}
|
}
|
||||||
@@ -43,16 +59,87 @@ interface LogFiltersProps {
|
|||||||
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
|
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
|
||||||
const PRESET_LIMITS = ['25', '50', '100', '200', '500', '1000'];
|
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({
|
export function LogFilters({
|
||||||
selectedDate,
|
selectedDate,
|
||||||
selectedLevel,
|
selectedLevel,
|
||||||
requestId,
|
requestId,
|
||||||
searchText,
|
searchText,
|
||||||
|
selectedStatusCodes,
|
||||||
|
selectedMethods,
|
||||||
|
selectedEndpoints,
|
||||||
limit,
|
limit,
|
||||||
onDateChange,
|
onDateChange,
|
||||||
onLevelChange,
|
onLevelChange,
|
||||||
onRequestIdChange,
|
onRequestIdChange,
|
||||||
onSearchTextChange,
|
onSearchTextChange,
|
||||||
|
onStatusCodesChange,
|
||||||
|
onMethodsChange,
|
||||||
|
onEndpointsChange,
|
||||||
onLimitChange,
|
onLimitChange,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
}: LogFiltersProps) {
|
}: LogFiltersProps) {
|
||||||
@@ -68,6 +155,10 @@ export function LogFilters({
|
|||||||
: undefined
|
: undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [statusSearch, setStatusSearch] = useState('');
|
||||||
|
const [methodSearch, setMethodSearch] = useState('');
|
||||||
|
const [endpointSearch, setEndpointSearch] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentIsPreset = PRESET_LIMITS.includes(limit.toString());
|
const currentIsPreset = PRESET_LIMITS.includes(limit.toString());
|
||||||
setIsCustom(!currentIsPreset);
|
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 (
|
return (
|
||||||
<Card className='mb-6'>
|
<Card className='mb-6'>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -137,7 +253,8 @@ export function LogFilters({
|
|||||||
Filters
|
Filters
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -197,6 +314,337 @@ export function LogFilters({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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'>
|
<div className='space-y-2'>
|
||||||
<Label htmlFor='request-id'>Request ID</Label>
|
<Label htmlFor='request-id'>Request ID</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
+84
-2
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { AppSidebar } from '@/components/app-sidebar';
|
import { AppSidebar } from '@/components/app-sidebar';
|
||||||
import { SiteHeader } from '@/components/site-header';
|
import { SiteHeader } from '@/components/site-header';
|
||||||
@@ -22,15 +22,66 @@ import { LogFilters } from './log-filters';
|
|||||||
import { LogEntryCard } from './log-entry-card';
|
import { LogEntryCard } from './log-entry-card';
|
||||||
import { LogDetailsDialog } from './log-details-dialog';
|
import { LogDetailsDialog } from './log-details-dialog';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'routstr-log-filters';
|
||||||
|
|
||||||
export default function LogsPage() {
|
export default function LogsPage() {
|
||||||
const [selectedDate, setSelectedDate] = useState<string>('all');
|
const [selectedDate, setSelectedDate] = useState<string>('all');
|
||||||
const [selectedLevel, setSelectedLevel] = useState<string>('all');
|
const [selectedLevel, setSelectedLevel] = useState<string>('all');
|
||||||
const [requestId, setRequestId] = useState<string>('');
|
const [requestId, setRequestId] = useState<string>('');
|
||||||
const [searchText, setSearchText] = 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 [limit, setLimit] = useState<number>(100);
|
||||||
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
|
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
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 {
|
const {
|
||||||
data: logsData,
|
data: logsData,
|
||||||
refetch: refetchLogs,
|
refetch: refetchLogs,
|
||||||
@@ -42,6 +93,9 @@ export default function LogsPage() {
|
|||||||
selectedLevel,
|
selectedLevel,
|
||||||
requestId,
|
requestId,
|
||||||
searchText,
|
searchText,
|
||||||
|
selectedStatusCodes,
|
||||||
|
selectedMethods,
|
||||||
|
selectedEndpoints,
|
||||||
limit,
|
limit,
|
||||||
],
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -50,6 +104,16 @@ export default function LogsPage() {
|
|||||||
level: selectedLevel === 'all' ? undefined : selectedLevel,
|
level: selectedLevel === 'all' ? undefined : selectedLevel,
|
||||||
request_id: requestId || undefined,
|
request_id: requestId || undefined,
|
||||||
search: searchText || 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,
|
limit: limit,
|
||||||
}),
|
}),
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
@@ -60,6 +124,9 @@ export default function LogsPage() {
|
|||||||
setSelectedLevel('all');
|
setSelectedLevel('all');
|
||||||
setRequestId('');
|
setRequestId('');
|
||||||
setSearchText('');
|
setSearchText('');
|
||||||
|
setSelectedStatusCodes([]);
|
||||||
|
setSelectedMethods([]);
|
||||||
|
setSelectedEndpoints([]);
|
||||||
setLimit(100);
|
setLimit(100);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,11 +167,17 @@ export default function LogsPage() {
|
|||||||
selectedLevel={selectedLevel}
|
selectedLevel={selectedLevel}
|
||||||
requestId={requestId}
|
requestId={requestId}
|
||||||
searchText={searchText}
|
searchText={searchText}
|
||||||
|
selectedStatusCodes={selectedStatusCodes}
|
||||||
|
selectedMethods={selectedMethods}
|
||||||
|
selectedEndpoints={selectedEndpoints}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
onDateChange={setSelectedDate}
|
onDateChange={setSelectedDate}
|
||||||
onLevelChange={setSelectedLevel}
|
onLevelChange={setSelectedLevel}
|
||||||
onRequestIdChange={setRequestId}
|
onRequestIdChange={setRequestId}
|
||||||
onSearchTextChange={setSearchText}
|
onSearchTextChange={setSearchText}
|
||||||
|
onStatusCodesChange={setSelectedStatusCodes}
|
||||||
|
onMethodsChange={setSelectedMethods}
|
||||||
|
onEndpointsChange={setSelectedEndpoints}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={setLimit}
|
||||||
onClearFilters={handleClearFilters}
|
onClearFilters={handleClearFilters}
|
||||||
/>
|
/>
|
||||||
@@ -122,13 +195,22 @@ export default function LogsPage() {
|
|||||||
{(selectedDate !== 'all' ||
|
{(selectedDate !== 'all' ||
|
||||||
selectedLevel !== 'all' ||
|
selectedLevel !== 'all' ||
|
||||||
requestId ||
|
requestId ||
|
||||||
searchText) && (
|
searchText ||
|
||||||
|
selectedStatusCodes.length > 0 ||
|
||||||
|
selectedMethods.length > 0 ||
|
||||||
|
selectedEndpoints.length > 0) && (
|
||||||
<CardDescription className='text-xs sm:text-sm'>
|
<CardDescription className='text-xs sm:text-sm'>
|
||||||
Showing logs
|
Showing logs
|
||||||
{selectedDate !== 'all' && ` for ${selectedDate}`}
|
{selectedDate !== 'all' && ` for ${selectedDate}`}
|
||||||
{selectedLevel !== 'all' && ` with level ${selectedLevel}`}
|
{selectedLevel !== 'all' && ` with level ${selectedLevel}`}
|
||||||
{requestId && ` with request ID ${requestId}`}
|
{requestId && ` with request ID ${requestId}`}
|
||||||
{searchText && ` matching "${searchText}"`}
|
{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>
|
</CardDescription>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export interface LogsResponse {
|
|||||||
level: string | null;
|
level: string | null;
|
||||||
request_id: string | null;
|
request_id: string | null;
|
||||||
search: string | null;
|
search: string | null;
|
||||||
|
status_codes: string | null;
|
||||||
|
methods: string | null;
|
||||||
|
endpoints: string | null;
|
||||||
limit: number;
|
limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,7 +208,12 @@ function ProviderBalance({
|
|||||||
return <Skeleton className='h-9 w-24' />;
|
return <Skeleton className='h-9 w-24' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !balanceData?.ok || !balanceData.balance_data) {
|
if (
|
||||||
|
error ||
|
||||||
|
!balanceData?.ok ||
|
||||||
|
balanceData.balance_data === undefined ||
|
||||||
|
balanceData.balance_data === null
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
-9257
File diff suppressed because it is too large
Load Diff
+10
-9
@@ -15,6 +15,7 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.10",
|
"@radix-ui/react-alert-dialog": "^1.1.10",
|
||||||
"@radix-ui/react-avatar": "^1.1.6",
|
"@radix-ui/react-avatar": "^1.1.6",
|
||||||
@@ -40,17 +41,17 @@
|
|||||||
"@radix-ui/react-toggle": "^1.1.6",
|
"@radix-ui/react-toggle": "^1.1.6",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.6",
|
"@radix-ui/react-toggle-group": "^1.1.6",
|
||||||
"@radix-ui/react-tooltip": "^1.2.3",
|
"@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",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.501.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "15.3.1",
|
"next": "15.5.9",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
@@ -67,21 +68,21 @@
|
|||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tanstack/react-query-devtools": "^5.74.4",
|
"@tanstack/react-query-devtools": "^5.91.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-next": "15.3.1",
|
"eslint-config-next": "15.5.9",
|
||||||
"eslint-config-prettier": "^10.1.2",
|
"eslint-config-prettier": "^10.1.2",
|
||||||
"eslint-plugin-prettier": "^5.2.6",
|
"eslint-plugin-prettier": "^5.2.6",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+544
-544
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -22,6 +22,12 @@
|
|||||||
"@/*": ["./*"]
|
"@/*": ["./*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user