mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-05 09:34:36 +00:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35c9ce5699 | ||
|
|
24015ebec1 | ||
|
|
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 | ||
|
|
634a473f50 | ||
|
|
ea655b748b | ||
|
|
2d247ddc8b | ||
|
|
c064452aea | ||
|
|
1c6a603042 | ||
|
|
84b0007b05 | ||
|
|
525476ccfa | ||
|
|
b54812cb04 | ||
|
|
ee508cbb3a | ||
|
|
0c61fdee07 | ||
|
|
b9418db31f | ||
|
|
71e7c2171b | ||
|
|
a3e8d5fd38 | ||
|
|
5b8e56f590 | ||
|
|
a6d0bd1a19 | ||
|
|
b01c7b2e56 | ||
|
|
d41c214d9e | ||
|
|
ec0fcfb48b | ||
|
|
82d2627c60 | ||
|
|
8df0c17bc3 | ||
|
|
7bc9ee0653 | ||
|
|
355f8601c1 |
@@ -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
|
||||||
|
|||||||
@@ -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 ###
|
||||||
+1
-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"
|
||||||
|
|||||||
@@ -206,19 +206,20 @@ def create_model_mappings(
|
|||||||
alias: str, model: "Model", provider: "BaseUpstreamProvider"
|
alias: str, model: "Model", provider: "BaseUpstreamProvider"
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set alias to model/provider if not set or if new model is preferred."""
|
"""Set alias to model/provider if not set or if new model is preferred."""
|
||||||
existing_model = model_instances.get(alias)
|
alias_lower = alias.lower()
|
||||||
|
existing_model = model_instances.get(alias_lower)
|
||||||
if not existing_model:
|
if not existing_model:
|
||||||
# No existing mapping, set it
|
# No existing mapping, set it
|
||||||
model_instances[alias] = model
|
model_instances[alias_lower] = model
|
||||||
provider_map[alias] = provider
|
provider_map[alias_lower] = provider
|
||||||
else:
|
else:
|
||||||
# Check if candidate should replace existing
|
# Check if candidate should replace existing
|
||||||
existing_provider = provider_map[alias]
|
existing_provider = provider_map[alias_lower]
|
||||||
if should_prefer_model(
|
if should_prefer_model(
|
||||||
model, provider, existing_model, existing_provider, alias
|
model, provider, existing_model, existing_provider, alias
|
||||||
):
|
):
|
||||||
model_instances[alias] = model
|
model_instances[alias_lower] = model
|
||||||
provider_map[alias] = provider
|
provider_map[alias_lower] = provider
|
||||||
|
|
||||||
def process_provider_models(
|
def process_provider_models(
|
||||||
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
|
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
|
||||||
|
|||||||
+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,
|
||||||
|
|||||||
+26
-3
@@ -142,6 +142,7 @@ async def refund_wallet_endpoint(
|
|||||||
authorization: Annotated[str, Header(...)],
|
authorization: Annotated[str, Header(...)],
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
|
logger.info("Refund request received", extra={"authorization": authorization})
|
||||||
if not authorization.startswith("Bearer "):
|
if not authorization.startswith("Bearer "):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
@@ -154,8 +155,10 @@ 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
|
||||||
|
|
||||||
|
logger.info("Refunding key: %s", key.dict())
|
||||||
if key.refund_currency == "sat":
|
if key.refund_currency == "sat":
|
||||||
remaining_balance = remaining_balance_msats // 1000
|
remaining_balance = remaining_balance_msats // 1000
|
||||||
else:
|
else:
|
||||||
@@ -171,6 +174,15 @@ async def refund_wallet_endpoint(
|
|||||||
if key.refund_address:
|
if key.refund_address:
|
||||||
from .core.settings import settings as global_settings
|
from .core.settings import settings as global_settings
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Sending refund to lnurl",
|
||||||
|
extra={
|
||||||
|
"remaining_balance": remaining_balance,
|
||||||
|
"refund_currency": key.refund_currency,
|
||||||
|
"refund_mint_url": key.refund_mint_url,
|
||||||
|
"refund_address": key.refund_address,
|
||||||
|
},
|
||||||
|
)
|
||||||
await send_to_lnurl(
|
await send_to_lnurl(
|
||||||
remaining_balance,
|
remaining_balance,
|
||||||
key.refund_currency or "sat",
|
key.refund_currency or "sat",
|
||||||
@@ -179,7 +191,16 @@ async def refund_wallet_endpoint(
|
|||||||
)
|
)
|
||||||
result = {"recipient": key.refund_address}
|
result = {"recipient": key.refund_address}
|
||||||
else:
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Sending refund as token",
|
||||||
|
extra={
|
||||||
|
"remaining_balance": remaining_balance,
|
||||||
|
"refund_currency": key.refund_currency,
|
||||||
|
"refund_mint_url": key.refund_mint_url,
|
||||||
|
},
|
||||||
|
)
|
||||||
refund_currency = key.refund_currency or "sat"
|
refund_currency = key.refund_currency or "sat"
|
||||||
|
|
||||||
token = await send_token(
|
token = await send_token(
|
||||||
remaining_balance, refund_currency, key.refund_mint_url
|
remaining_balance, refund_currency, key.refund_mint_url
|
||||||
)
|
)
|
||||||
@@ -190,10 +211,12 @@ async def refund_wallet_endpoint(
|
|||||||
else:
|
else:
|
||||||
result["msats"] = str(remaining_balance_msats)
|
result["msats"] = str(remaining_balance_msats)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException as e:
|
||||||
|
logger.error("Refund failed", extra={"exception": e})
|
||||||
# Re-raise HTTP exceptions (like 400 for balance too small)
|
# Re-raise HTTP exceptions (like 400 for balance too small)
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error("Refund failed", extra={"exception": e})
|
||||||
# If refund fails, don't modify the database
|
# If refund fails, don't modify the database
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if (
|
if (
|
||||||
@@ -204,7 +227,7 @@ async def refund_wallet_endpoint(
|
|||||||
):
|
):
|
||||||
raise HTTPException(status_code=503, detail="Mint service unavailable")
|
raise HTTPException(status_code=503, detail="Mint service unavailable")
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=500, detail="Refund failed")
|
raise HTTPException(status_code=500, detail="Refund failed: " + error_msg)
|
||||||
|
|
||||||
await _refund_cache_set(bearer_value, result)
|
await _refund_cache_set(bearer_value, result)
|
||||||
|
|
||||||
|
|||||||
+100
-110
@@ -1493,20 +1493,8 @@ class ModelCreate(BaseModel):
|
|||||||
per_request_limits: dict[str, object] | None = None
|
per_request_limits: dict[str, object] | None = None
|
||||||
top_provider: dict[str, object] | None = None
|
top_provider: dict[str, object] | None = None
|
||||||
upstream_provider_id: int | None = None
|
upstream_provider_id: int | None = None
|
||||||
enabled: bool = True
|
canonical_slug: str | None = None
|
||||||
|
alias_ids: list[str] | None = None
|
||||||
|
|
||||||
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
|
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
@@ -2416,44 +2404,80 @@ async def admin_upstream_providers(request: Request) -> str:
|
|||||||
"/api/upstream-providers/{provider_id}/models",
|
"/api/upstream-providers/{provider_id}/models",
|
||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
)
|
)
|
||||||
async def create_provider_model(
|
async def upsert_provider_model(
|
||||||
provider_id: int, payload: ModelCreate
|
provider_id: int, payload: ModelCreate
|
||||||
) -> dict[str, object]:
|
) -> 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:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||||
if not provider:
|
if not provider:
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
raise HTTPException(status_code=404, detail="Provider not found")
|
||||||
|
|
||||||
exists = await session.get(ModelRow, (payload.id, provider_id))
|
# Try to get existing model
|
||||||
if exists:
|
existing_row = await session.get(ModelRow, (payload.id, provider_id))
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail="Model with this ID already exists for this provider",
|
|
||||||
)
|
|
||||||
|
|
||||||
row = ModelRow(
|
if existing_row:
|
||||||
id=payload.id,
|
# Update existing model
|
||||||
name=payload.name,
|
logger.info(f"Updating existing model: {payload.id}")
|
||||||
description=payload.description,
|
existing_row.name = payload.name
|
||||||
created=int(payload.created),
|
existing_row.description = payload.description
|
||||||
context_length=int(payload.context_length),
|
existing_row.created = int(payload.created)
|
||||||
architecture=json.dumps(payload.architecture),
|
existing_row.context_length = int(payload.context_length)
|
||||||
pricing=json.dumps(payload.pricing),
|
existing_row.architecture = json.dumps(payload.architecture)
|
||||||
sats_pricing=None,
|
existing_row.pricing = json.dumps(payload.pricing)
|
||||||
per_request_limits=(
|
existing_row.sats_pricing = None
|
||||||
|
existing_row.per_request_limits = (
|
||||||
json.dumps(payload.per_request_limits)
|
json.dumps(payload.per_request_limits)
|
||||||
if payload.per_request_limits is not None
|
if payload.per_request_limits is not None
|
||||||
else None
|
else None
|
||||||
),
|
)
|
||||||
top_provider=(
|
existing_row.top_provider = (
|
||||||
json.dumps(payload.top_provider) if payload.top_provider else None
|
json.dumps(payload.top_provider) if payload.top_provider else None
|
||||||
),
|
)
|
||||||
upstream_provider_id=provider_id,
|
existing_row.canonical_slug = payload.canonical_slug
|
||||||
enabled=payload.enabled,
|
existing_row.alias_ids = (
|
||||||
)
|
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
||||||
session.add(row)
|
)
|
||||||
await session.commit()
|
existing_row.enabled = payload.enabled
|
||||||
await session.refresh(row)
|
|
||||||
|
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()
|
await refresh_model_maps()
|
||||||
return _row_to_model(
|
return _row_to_model(
|
||||||
@@ -2461,6 +2485,20 @@ async def create_provider_model(
|
|||||||
).dict() # type: ignore
|
).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(
|
@admin_router.get(
|
||||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
@@ -2481,76 +2519,6 @@ async def get_provider_model(provider_id: int, model_id: str) -> dict[str, objec
|
|||||||
).dict() # type: ignore
|
).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(
|
@admin_router.delete(
|
||||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
@@ -3112,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]:
|
||||||
"""
|
"""
|
||||||
@@ -3122,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3142,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-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)
|
||||||
@@ -68,6 +76,10 @@ class ModelRow(SQLModel, table=True): # type: ignore
|
|||||||
sats_pricing: str | None = Field(default=None)
|
sats_pricing: str | None = Field(default=None)
|
||||||
per_request_limits: str | None = Field(default=None)
|
per_request_limits: str | None = Field(default=None)
|
||||||
top_provider: 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")
|
enabled: bool = Field(default=True, description="Whether this model is enabled")
|
||||||
upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models")
|
upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models")
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+11
-14
@@ -14,7 +14,6 @@ from ..balance import balance_router, deprecated_wallet_router
|
|||||||
from ..discovery import providers_cache_refresher, providers_router
|
from ..discovery import providers_cache_refresher, providers_router
|
||||||
from ..nip91 import announce_provider
|
from ..nip91 import announce_provider
|
||||||
from ..payment.models import (
|
from ..payment.models import (
|
||||||
cleanup_enabled_models_periodically,
|
|
||||||
models_router,
|
models_router,
|
||||||
update_sats_pricing,
|
update_sats_pricing,
|
||||||
)
|
)
|
||||||
@@ -34,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
|
||||||
@@ -49,7 +48,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
nip91_task = None
|
nip91_task = None
|
||||||
providers_task = None
|
providers_task = None
|
||||||
models_refresh_task = None
|
models_refresh_task = None
|
||||||
models_cleanup_task = None
|
|
||||||
model_maps_refresh_task = None
|
model_maps_refresh_task = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -63,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:
|
||||||
@@ -80,13 +82,17 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
_update_prices_task = asyncio.create_task(_update_prices())
|
_update_prices_task = asyncio.create_task(_update_prices())
|
||||||
_initialize_upstreams_task = asyncio.create_task(initialize_upstreams())
|
_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())
|
btc_price_task = asyncio.create_task(update_prices_periodically())
|
||||||
pricing_task = asyncio.create_task(update_sats_pricing())
|
pricing_task = asyncio.create_task(update_sats_pricing())
|
||||||
if global_settings.models_refresh_interval_seconds > 0:
|
if global_settings.models_refresh_interval_seconds > 0:
|
||||||
models_refresh_task = asyncio.create_task(
|
models_refresh_task = asyncio.create_task(
|
||||||
refresh_upstreams_models_periodically(get_upstreams())
|
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())
|
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
|
||||||
payout_task = asyncio.create_task(periodic_payout())
|
payout_task = asyncio.create_task(periodic_payout())
|
||||||
if global_settings.nsec:
|
if global_settings.nsec:
|
||||||
@@ -94,11 +100,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
if global_settings.providers_refresh_interval_seconds > 0:
|
if global_settings.providers_refresh_interval_seconds > 0:
|
||||||
providers_task = asyncio.create_task(providers_cache_refresher())
|
providers_task = asyncio.create_task(providers_cache_refresher())
|
||||||
|
|
||||||
# ensure both setup tasks complete
|
|
||||||
await asyncio.gather(
|
|
||||||
_update_prices_task, _initialize_upstreams_task, return_exceptions=True
|
|
||||||
)
|
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -125,8 +126,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
providers_task.cancel()
|
providers_task.cancel()
|
||||||
if models_refresh_task is not None:
|
if models_refresh_task is not None:
|
||||||
models_refresh_task.cancel()
|
models_refresh_task.cancel()
|
||||||
if models_cleanup_task is not None:
|
|
||||||
models_cleanup_task.cancel()
|
|
||||||
if model_maps_refresh_task is not None:
|
if model_maps_refresh_task is not None:
|
||||||
model_maps_refresh_task.cancel()
|
model_maps_refresh_task.cancel()
|
||||||
|
|
||||||
@@ -144,8 +143,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
tasks_to_wait.append(providers_task)
|
tasks_to_wait.append(providers_task)
|
||||||
if models_refresh_task is not None:
|
if models_refresh_task is not None:
|
||||||
tasks_to_wait.append(models_refresh_task)
|
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:
|
if model_maps_refresh_task is not None:
|
||||||
tasks_to_wait.append(model_maps_refresh_task)
|
tasks_to_wait.append(model_maps_refresh_task)
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,16 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
"headers": {
|
"headers": {
|
||||||
k: v
|
k: v
|
||||||
for k, v in request.headers.items()
|
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,
|
"body_size": len(request_body) if request_body else 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -54,6 +54,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
|
||||||
|
|
||||||
|
|||||||
+11
-112
@@ -5,10 +5,9 @@ import random
|
|||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic.v1 import BaseModel
|
from pydantic.v1 import BaseModel
|
||||||
from sqlmodel import select
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
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.logging import get_logger
|
||||||
from ..core.settings import settings
|
from ..core.settings import settings
|
||||||
from .price import sats_usd_price
|
from .price import sats_usd_price
|
||||||
@@ -185,6 +184,7 @@ def _row_to_model(
|
|||||||
enabled=row.enabled,
|
enabled=row.enabled,
|
||||||
upstream_provider_id=row.upstream_provider_id,
|
upstream_provider_id=row.upstream_provider_id,
|
||||||
canonical_slug=getattr(row, "canonical_slug", None),
|
canonical_slug=getattr(row, "canonical_slug", None),
|
||||||
|
alias_ids=json.loads(row.alias_ids) if row.alias_ids else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
if apply_provider_fee:
|
if apply_provider_fee:
|
||||||
@@ -382,7 +382,7 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
|
|||||||
|
|
||||||
async def _update_sats_pricing_once() -> None:
|
async def _update_sats_pricing_once() -> None:
|
||||||
"""Update sats pricing once for all provider models (in-memory only)."""
|
"""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()
|
upstreams = get_upstreams()
|
||||||
sats_to_usd = sats_usd_price()
|
sats_to_usd = sats_usd_price()
|
||||||
@@ -399,6 +399,7 @@ async def _update_sats_pricing_once() -> None:
|
|||||||
|
|
||||||
if updated_count > 0:
|
if updated_count > 0:
|
||||||
logger.info("Updated sats pricing", extra={"models_updated": updated_count})
|
logger.info("Updated sats pricing", extra={"models_updated": updated_count})
|
||||||
|
await refresh_model_maps()
|
||||||
|
|
||||||
|
|
||||||
async def update_sats_pricing() -> None:
|
async def update_sats_pricing() -> None:
|
||||||
@@ -409,7 +410,13 @@ async def update_sats_pricing() -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -433,114 +440,6 @@ async def update_sats_pricing() -> None:
|
|||||||
logger.error(f"Error updating sats pricing: {e}")
|
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
|
|
||||||
|
|
||||||
|
|
||||||
@models_router.get("/v1/models")
|
@models_router.get("/v1/models")
|
||||||
@models_router.get("/models", include_in_schema=False)
|
@models_router.get("/models", include_in_schema=False)
|
||||||
async def models(session: AsyncSession = Depends(get_session)) -> dict:
|
async def models(session: AsyncSession = Depends(get_session)) -> dict:
|
||||||
|
|||||||
+71
-50
@@ -70,7 +70,7 @@ def get_model_instance(model_id: str) -> Model | None:
|
|||||||
|
|
||||||
def get_provider_for_model(model_id: str) -> BaseUpstreamProvider | None:
|
def get_provider_for_model(model_id: str) -> BaseUpstreamProvider | None:
|
||||||
"""Get UpstreamProvider for model ID from global cache."""
|
"""Get UpstreamProvider for model ID from global cache."""
|
||||||
return _provider_map.get(model_id)
|
return _provider_map.get(model_id.lower())
|
||||||
|
|
||||||
|
|
||||||
def get_unique_models() -> list[Model]:
|
def get_unique_models() -> list[Model]:
|
||||||
@@ -80,31 +80,27 @@ def get_unique_models() -> list[Model]:
|
|||||||
|
|
||||||
async def refresh_model_maps() -> None:
|
async def refresh_model_maps() -> None:
|
||||||
"""Refresh global model and provider maps using the cost-based algorithm."""
|
"""Refresh global model and provider maps using the cost-based algorithm."""
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
global _model_instances, _provider_map, _unique_models
|
global _model_instances, _provider_map, _unique_models
|
||||||
|
|
||||||
# Gather database overrides and disabled models
|
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
result = await session.exec(select(ModelRow).where(ModelRow.enabled))
|
# Fetch all providers with their models in a single logical operation
|
||||||
override_rows = result.all()
|
query = select(UpstreamProviderRow).options(
|
||||||
|
selectinload(UpstreamProviderRow.models) # type: ignore
|
||||||
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
|
|
||||||
)
|
)
|
||||||
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:
|
||||||
|
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(
|
_model_instances, _provider_map, _unique_models = create_model_mappings(
|
||||||
upstreams=_upstreams,
|
upstreams=_upstreams,
|
||||||
@@ -141,20 +137,14 @@ 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")
|
||||||
|
|
||||||
model_obj = get_model_instance(model_id)
|
model_obj = get_model_instance(model_id)
|
||||||
if not model_obj:
|
if not model_obj:
|
||||||
@@ -180,9 +170,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)
|
||||||
@@ -197,28 +192,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)
|
||||||
@@ -321,6 +324,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:
|
||||||
|
|||||||
+1221
-1
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -7,6 +8,7 @@ from typing import TYPE_CHECKING
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..core.settings import Settings
|
from ..core.settings import Settings
|
||||||
|
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
from ..core import get_logger
|
from ..core import get_logger
|
||||||
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
|
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
|
||||||
@@ -145,6 +147,17 @@ async def refresh_upstreams_models_periodically(
|
|||||||
f"Error refreshing models for {upstream.base_url}",
|
f"Error refreshing models for {upstream.base_url}",
|
||||||
extra={"error": str(e), "error_type": type(e).__name__},
|
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:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -166,8 +179,6 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
|||||||
Seeds database with providers from settings if empty, then loads and instantiates
|
Seeds database with providers from settings if empty, then loads and instantiates
|
||||||
provider instances from database records, and refreshes their models cache.
|
provider instances from database records, and refreshes their models cache.
|
||||||
"""
|
"""
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from ..core.settings import settings
|
from ..core.settings import settings
|
||||||
|
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
@@ -183,16 +194,16 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
|||||||
result = await session.exec(select(UpstreamProviderRow))
|
result = await session.exec(select(UpstreamProviderRow))
|
||||||
existing_providers = result.all()
|
existing_providers = result.all()
|
||||||
|
|
||||||
upstreams: list[BaseUpstreamProvider] = []
|
async def _init_single_provider(
|
||||||
for provider_row in existing_providers:
|
provider_row: UpstreamProviderRow,
|
||||||
|
) -> BaseUpstreamProvider | None:
|
||||||
if not provider_row.enabled:
|
if not provider_row.enabled:
|
||||||
logger.debug(f"Skipping disabled provider: {provider_row.base_url}")
|
logger.debug(f"Skipping disabled provider: {provider_row.base_url}")
|
||||||
continue
|
return None
|
||||||
|
|
||||||
provider = _instantiate_provider(provider_row)
|
provider = _instantiate_provider(provider_row)
|
||||||
if provider:
|
if provider:
|
||||||
await provider.refresh_models_cache()
|
await provider.refresh_models_cache()
|
||||||
upstreams.append(provider)
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Initialized {provider_row.provider_type} provider",
|
f"Initialized {provider_row.provider_type} provider",
|
||||||
extra={
|
extra={
|
||||||
@@ -200,6 +211,12 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
|||||||
"models_cached": len(provider.get_cached_models()),
|
"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
|
return upstreams
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -40,16 +40,23 @@ async def recieve_token(
|
|||||||
async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]:
|
async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]:
|
||||||
"""Internal send function - returns amount and serialized token"""
|
"""Internal send function - returns amount and serialized token"""
|
||||||
wallet: Wallet = await get_wallet(mint_url or settings.primary_mint, unit)
|
wallet: Wallet = await get_wallet(mint_url or settings.primary_mint, unit)
|
||||||
|
logger.info("Sending", extra={"amount": amount, "unit": unit, "mint_url": mint_url})
|
||||||
proofs = get_proofs_per_mint_and_unit(
|
proofs = get_proofs_per_mint_and_unit(
|
||||||
wallet, mint_url or settings.primary_mint, unit
|
wallet, mint_url or settings.primary_mint, unit
|
||||||
)
|
)
|
||||||
|
logger.info("Proofs", extra={"proofs": proofs})
|
||||||
|
logger.info(
|
||||||
|
"Selecting to send",
|
||||||
|
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
||||||
|
)
|
||||||
send_proofs, _ = await wallet.select_to_send(
|
send_proofs, _ = await wallet.select_to_send(
|
||||||
proofs, amount, set_reserved=True, include_fees=False
|
proofs, amount, set_reserved=True, include_fees=False
|
||||||
)
|
)
|
||||||
|
logger.info("Send proofs", extra={"send_proofs": send_proofs})
|
||||||
token = await wallet.serialize_proofs(
|
token = await wallet.serialize_proofs(
|
||||||
send_proofs, include_dleq=False, legacy=False, memo=None
|
send_proofs, include_dleq=False, legacy=False, memo=None
|
||||||
)
|
)
|
||||||
|
logger.info("Token created", extra={"token": token})
|
||||||
return amount, token
|
return amount, token
|
||||||
|
|
||||||
|
|
||||||
@@ -81,7 +88,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)
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ export function ModelSearchFilter({
|
|||||||
model.name.toLowerCase().includes(query) ||
|
model.name.toLowerCase().includes(query) ||
|
||||||
model.full_name.toLowerCase().includes(query) ||
|
model.full_name.toLowerCase().includes(query) ||
|
||||||
model.provider.toLowerCase().includes(query) ||
|
model.provider.toLowerCase().includes(query) ||
|
||||||
|
(model.alias_ids &&
|
||||||
|
model.alias_ids.some((alias) =>
|
||||||
|
alias.toLowerCase().includes(query)
|
||||||
|
)) ||
|
||||||
(model.description &&
|
(model.description &&
|
||||||
model.description.toLowerCase().includes(query)) ||
|
model.description.toLowerCase().includes(query)) ||
|
||||||
model.modelType.toLowerCase().includes(query)
|
model.modelType.toLowerCase().includes(query)
|
||||||
|
|||||||
@@ -542,6 +542,7 @@ export function ModelSelector({
|
|||||||
top_provider: null,
|
top_provider: null,
|
||||||
upstream_provider_id: providerId,
|
upstream_provider_id: providerId,
|
||||||
enabled: model.isEnabled,
|
enabled: model.isEnabled,
|
||||||
|
alias_ids: model.alias_ids || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
setModelDialogState({
|
setModelDialogState({
|
||||||
@@ -585,6 +586,7 @@ export function ModelSelector({
|
|||||||
top_provider: null,
|
top_provider: null,
|
||||||
upstream_provider_id: providerId,
|
upstream_provider_id: providerId,
|
||||||
enabled: model.isEnabled,
|
enabled: model.isEnabled,
|
||||||
|
alias_ids: model.alias_ids || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
setModelDialogState({
|
setModelDialogState({
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const ModelSchema = z.object({
|
|||||||
// API key type indicators
|
// API key type indicators
|
||||||
has_own_api_key: z.boolean(),
|
has_own_api_key: z.boolean(),
|
||||||
api_key_type: z.string(), // "individual" or "group"
|
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
|
// Schema for a model with additional provider-specific settings
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ export interface AdminModelAsModel {
|
|||||||
soft_deleted?: boolean;
|
soft_deleted?: boolean;
|
||||||
has_own_api_key: boolean;
|
has_own_api_key: boolean;
|
||||||
api_key_type: string;
|
api_key_type: string;
|
||||||
|
alias_ids?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminModelGroup {
|
export interface AdminModelGroup {
|
||||||
@@ -207,6 +208,7 @@ export class AdminService {
|
|||||||
soft_deleted: !adminModel.enabled,
|
soft_deleted: !adminModel.enabled,
|
||||||
has_own_api_key: false,
|
has_own_api_key: false,
|
||||||
api_key_type: 'group',
|
api_key_type: 'group',
|
||||||
|
alias_ids: adminModel.alias_ids,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,8 +356,9 @@ export class AdminService {
|
|||||||
original: data.pricing,
|
original: data.pricing,
|
||||||
converted: payload.pricing,
|
converted: payload.pricing,
|
||||||
});
|
});
|
||||||
const model = await apiClient.patch<AdminModel>(
|
// Use the same POST endpoint for both create and update (upsert)
|
||||||
`/admin/api/upstream-providers/${providerId}/models/${encodeURIComponent(modelId)}`,
|
const model = await apiClient.post<AdminModel>(
|
||||||
|
`/admin/api/upstream-providers/${providerId}/models`,
|
||||||
payload
|
payload
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
|||||||
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