Compare commits

..
Author SHA1 Message Date
Shroominic 8b5524f720 feat: add option to disable testnut mock upstream 2026-01-05 13:11:41 +01:00
9qeklajcandGitHub eeb70e4ee5 Merge pull request #289 from Routstr/282-better-filltering
#282 more filter options
2026-01-03 23:27:22 +01:00
9qeklajcandGitHub a3b410b467 Merge pull request #284 from Routstr/274-do-not-charge-when-empty-content
#274 do not charge user for empty response by upstream
2026-01-03 23:26:54 +01:00
9qeklajc bdf0e2c192 #282 more filter options 2026-01-03 22:49:35 +01:00
9qeklajc 334453f934 #274 do not change user for empty response by upstream 2026-01-03 21:27:19 +01:00
12 changed files with 710 additions and 273 deletions
+5 -41
View File
@@ -1,7 +1,5 @@
"""Model prioritization algorithm for selecting cheapest upstream providers."""
from __future__ import annotations
from typing import TYPE_CHECKING
from .core.logging import get_logger
@@ -159,21 +157,15 @@ def create_model_mappings(
upstreams: list["BaseUpstreamProvider"],
overrides_by_id: dict[str, tuple],
disabled_model_ids: set[str],
) -> tuple[
dict[str, "Model"],
dict[str, "BaseUpstreamProvider"],
dict[str, "Model"],
dict[str, list["BaseUpstreamProvider"]],
]:
) -> tuple[dict[str, "Model"], dict[str, "BaseUpstreamProvider"], dict[str, "Model"]]:
"""Create optimal model mappings based on cost and provider preferences.
This is the main entry point for the algorithm. It processes all upstream providers
and creates three mappings based on cost optimization:
1. model_instances: alias -> Model (all model aliases mapped to their Model objects)
2. provider_map: alias -> UpstreamProvider (the BEST provider to use for each alias)
2. provider_map: alias -> UpstreamProvider (which provider to use for each alias)
3. unique_models: base_id -> Model (unique models without provider prefixes)
4. provider_candidates_map: alias -> list[UpstreamProvider] (all providers offering the model, sorted by preference)
The algorithm:
- Processes non-OpenRouter providers first (they're typically cheaper)
@@ -186,7 +178,7 @@ def create_model_mappings(
disabled_model_ids: Set of model IDs that should be excluded
Returns:
Tuple of (model_instances, provider_map, unique_models, provider_candidates_map)
Tuple of (model_instances, provider_map, unique_models)
"""
from .payment.models import _row_to_model
from .upstream.helpers import resolve_model_alias
@@ -194,10 +186,9 @@ def create_model_mappings(
model_instances: dict[str, "Model"] = {}
provider_map: dict[str, "BaseUpstreamProvider"] = {}
unique_models: dict[str, "Model"] = {}
provider_candidates_map: dict[str, list["BaseUpstreamProvider"]] = {}
# Separate OpenRouter from other providers
openrouter: BaseUpstreamProvider | None = None
openrouter: "BaseUpstreamProvider" | None = None
other_upstreams: list["BaseUpstreamProvider"] = []
for upstream in upstreams:
@@ -216,13 +207,6 @@ def create_model_mappings(
) -> None:
"""Set alias to model/provider if not set or if new model is preferred."""
alias_lower = alias.lower()
# Add to candidates list, to be used later as fallback
if alias_lower not in provider_candidates_map:
provider_candidates_map[alias_lower] = [provider]
else:
provider_candidates_map[alias_lower].append(provider)
existing_model = model_instances.get(alias_lower)
if not existing_model:
# No existing mapping, set it
@@ -292,26 +276,6 @@ def create_model_mappings(
if openrouter:
process_provider_models(openrouter, is_openrouter=True)
# Sort and filter provider candidates for each alias using provider_map as reference
# We only keep entries that have more than one provider.
final_candidates_map: dict[str, list["BaseUpstreamProvider"]] = {}
for alias_lower, best_provider in provider_map.items():
candidates = provider_candidates_map.get(alias_lower, [])
# Remove duplicates
unique_candidates = []
seen = set()
for c in candidates:
if c not in seen:
unique_candidates.append(c)
seen.add(c)
if len(unique_candidates) > 1:
# Keep the best one at the front, others follow.
if best_provider in unique_candidates:
unique_candidates.remove(best_provider)
unique_candidates.insert(0, best_provider)
final_candidates_map[alias_lower] = unique_candidates
# Log provider distribution
provider_counts: dict[str, int] = {}
for provider in provider_map.values():
@@ -323,4 +287,4 @@ def create_model_mappings(
extra={"provider_distribution": provider_counts},
)
return model_instances, provider_map, unique_models, provider_candidates_map
return model_instances, provider_map, unique_models
+22
View File
@@ -3080,6 +3080,9 @@ async def get_logs_api(
level: str | None = None,
request_id: str | None = None,
search: str | None = None,
status_codes: str | None = Query(None, description="Comma-separated status codes"),
methods: str | None = Query(None, description="Comma-separated HTTP methods"),
endpoints: str | None = Query(None, description="Comma-separated endpoints"),
limit: int = 100,
) -> dict[str, object]:
"""
@@ -3090,16 +3093,32 @@ async def get_logs_api(
level: Filter by log level
request_id: Filter by request ID
search: Search text in message and name fields (case-insensitive)
status_codes: Comma-separated list of HTTP status codes
methods: Comma-separated list of HTTP methods
endpoints: Comma-separated list of endpoints
limit: Maximum number of entries to return
Returns:
Dict containing logs and filter metadata
"""
status_code_list = None
if status_codes:
try:
status_code_list = [int(s.strip()) for s in status_codes.split(",")]
except ValueError:
pass
method_list = [m.strip() for m in methods.split(",")] if methods else None
endpoint_list = [e.strip() for e in endpoints.split(",")] if endpoints else None
log_entries = log_manager.search_logs(
date=date,
level=level,
request_id=request_id,
search_text=search,
status_codes=status_code_list,
methods=method_list,
endpoints=endpoint_list,
limit=limit,
)
@@ -3110,6 +3129,9 @@ async def get_logs_api(
"level": level,
"request_id": request_id,
"search": search,
"status_codes": status_codes,
"methods": methods,
"endpoints": endpoints,
"limit": limit,
}
+43 -1
View File
@@ -105,6 +105,9 @@ class LogManager:
level: str | None = None,
request_id: str | None = None,
search_text: str | None = None,
status_codes: list[int] | None = None,
methods: list[str] | None = None,
endpoints: list[str] | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
"""
@@ -134,7 +137,13 @@ class LogManager:
for log_data in iterator:
if not self._matches_filters(
log_data, level, request_id, search_text_lower
log_data,
level,
request_id,
search_text_lower,
status_codes,
methods,
endpoints,
):
continue
@@ -153,6 +162,9 @@ class LogManager:
level: str | None,
request_id: str | None,
search_text_lower: str | None,
status_codes: list[int] | None = None,
methods: list[str] | None = None,
endpoints: list[str] | None = None,
) -> bool:
if level and log_data.get("levelname", "").upper() != level.upper():
return False
@@ -160,6 +172,36 @@ class LogManager:
if request_id and log_data.get("request_id") != request_id:
return False
if status_codes:
entry_status = log_data.get("status_code")
if entry_status is not None:
try:
if int(entry_status) not in status_codes:
return False
except (ValueError, TypeError):
return False
else:
return False
if methods:
entry_method = log_data.get("method", "").upper()
if entry_method not in [m.upper() for m in methods]:
return False
if endpoints:
entry_path = log_data.get("path", "")
matched = False
for endpoint in endpoints:
clean_endpoint = endpoint.lstrip("/")
if entry_path.startswith(clean_endpoint):
matched = True
break
if clean_endpoint in entry_path:
matched = True
break
if not matched:
return False
if search_text_lower:
message = str(log_data.get("message", "")).lower()
name = str(log_data.get("name", "")).lower()
+3
View File
@@ -47,6 +47,9 @@ class Settings(BaseSettings):
receive_ln_address: str = Field(default="", env="RECEIVE_LN_ADDRESS")
primary_mint: str = Field(default="", env="PRIMARY_MINT_URL")
primary_mint_unit: str = Field(default="sat", env="PRIMARY_MINT_UNIT")
disable_testnut_mock_upstream: bool = Field(
default=False, env="DISABLE_TESTNUT_MOCK_UPSTREAM"
)
# Pricing
# Default behavior: derive pricing from MODELS
+22 -11
View File
@@ -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:
logger.warning(
"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"),
},
)
return cost_data
return MaxCostData(
base_msats=0,
input_msats=0,
output_msats=0,
total_msats=0,
)
usage_data = response_data["usage"]
@@ -178,7 +176,12 @@ async def calculate_cost( # todo: can be sync
"model": response_data.get("model", "unknown"),
},
)
return cost_data
return MaxCostData(
base_msats=max_cost,
input_msats=0,
output_msats=0,
total_msats=max_cost,
)
input_tokens = usage_data.get("prompt_tokens", 0)
output_tokens = usage_data.get("completion_tokens", 0)
@@ -192,8 +195,16 @@ async def calculate_cost( # todo: can be sync
)
# 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_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)
+63 -193
View File
@@ -1,7 +1,6 @@
import json
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from sqlmodel import select
@@ -27,7 +26,7 @@ from .payment.helpers import (
from .payment.models import Model
from .upstream import BaseUpstreamProvider
from .upstream.helpers import init_upstreams
from .wallet import deserialize_token_from_string, recieve_token
from .wallet import deserialize_token_from_string
logger = get_logger(__name__)
proxy_router = APIRouter()
@@ -35,9 +34,6 @@ proxy_router = APIRouter()
_upstreams: list[BaseUpstreamProvider] = []
_model_instances: dict[str, Model] = {} # All aliases -> Model
_provider_map: dict[str, BaseUpstreamProvider] = {} # All aliases -> Provider
_provider_candidates_map: dict[
str, list[BaseUpstreamProvider]
] = {} # All aliases -> [Providers]
_unique_models: dict[str, Model] = {} # Unique model.id -> Model (no duplicates)
@@ -79,21 +75,6 @@ def get_provider_for_model(model_id: str) -> BaseUpstreamProvider | None:
return _provider_map.get(model_id.lower())
def get_providers_for_model(model_id: str) -> list[BaseUpstreamProvider]:
"""Get list of prioritized UpstreamProviders for model ID from global cache.
If multiple providers are available, returns the sorted list.
Otherwise, returns a list containing only the single best provider.
"""
candidates = _provider_candidates_map.get(model_id.lower(), [])
if candidates:
return candidates
# Fallback to the single best provider if no multi-provider candidates exist
best = get_provider_for_model(model_id)
return [best] if best else []
def get_unique_models() -> list[Model]:
"""Get list of unique models (no duplicates from aliases)."""
return list(_unique_models.values())
@@ -103,7 +84,7 @@ async def refresh_model_maps() -> None:
"""Refresh global model and provider maps using the cost-based algorithm."""
from sqlalchemy.orm import selectinload
global _model_instances, _provider_map, _unique_models, _provider_candidates_map
global _model_instances, _provider_map, _unique_models
async with create_session() as session:
# Fetch all providers with their models in a single logical operation
@@ -123,12 +104,10 @@ async def refresh_model_maps() -> None:
else:
disabled_model_ids.add(model.id)
_model_instances, _provider_map, _unique_models, _provider_candidates_map = (
create_model_mappings(
upstreams=_upstreams,
overrides_by_id=overrides_by_id,
disabled_model_ids=disabled_model_ids,
)
_model_instances, _provider_map, _unique_models = create_model_mappings(
upstreams=_upstreams,
overrides_by_id=overrides_by_id,
disabled_model_ids=disabled_model_ids,
)
@@ -169,7 +148,10 @@ async def proxy(
else:
model_id = request_body_dict.get("model", "unknown")
if "https://testnut.cashu.space" in settings.cashu_mints:
if (
"https://testnut.cashu.space" in settings.cashu_mints
and not settings.disable_testnut_mock_upstream
):
try:
token_str = None
if x_cashu_header := headers.get("x-cashu"):
@@ -193,8 +175,8 @@ async def proxy(
"invalid_model", f"Model '{model_id}' not found", 400, request=request
)
upstreams = get_providers_for_model(model_id)
if not upstreams:
upstream = get_provider_for_model(model_id)
if not upstream:
return create_error_response(
"invalid_model",
f"No provider found for model '{model_id}'",
@@ -211,80 +193,14 @@ async def proxy(
check_token_balance(headers, request_body_dict, max_cost_for_model)
if x_cashu := headers.get("x-cashu", None):
# Redeem token once before trying any providers
amount, unit, mint = await recieve_token(x_cashu)
# Fallback for X-Cashu payments
last_exception = None
for i, upstream in enumerate(upstreams):
try:
# Prepare headers for this specific upstream
upstream_headers = upstream.prepare_headers(dict(request.headers))
if is_responses_api:
return await upstream.forward_x_cashu_responses_request(
request,
path,
upstream_headers,
amount,
unit,
max_cost_for_model,
model_obj,
mint,
)
else:
return await upstream.forward_x_cashu_request(
request,
path,
upstream_headers,
amount,
unit,
max_cost_for_model,
model_obj,
mint,
)
except (httpx.TimeoutException, httpx.ConnectError) as e:
logger.warning(
f"Upstream provider {i + 1}/{len(upstreams)} ({upstream.provider_type}) timed out, trying fallback",
extra={
"model": model_id,
"error": str(e),
"attempt": i + 1,
},
)
last_exception = e
continue
# If we get here, all providers failed
# Since the token was already redeemed, we must issue a refund
logger.error(
"All providers failed for X-Cashu request, issuing emergency refund",
extra={"amount": amount, "unit": unit, "mint": mint},
)
# Try to use the first provider's refund mechanism
refund_token = await upstreams[0].send_refund(amount - 60, unit, mint)
error_message = "All upstream providers timed out"
if isinstance(last_exception, httpx.ConnectError):
error_message = "Unable to connect to any upstream service"
error_response = Response(
content=json.dumps(
{
"error": {
"message": error_message,
"type": "upstream_error",
"code": 504,
"refund_token": refund_token,
}
}
),
status_code=504,
media_type="application/json",
)
error_response.headers["X-Cashu"] = refund_token
return error_response
if is_responses_api:
return await upstream.handle_x_cashu_responses(
request, x_cashu, path, max_cost_for_model, model_obj
)
else:
return await upstream.handle_x_cashu(
request, x_cashu, path, max_cost_for_model, model_obj
)
elif auth := headers.get("authorization", None):
key = await get_bearer_token_key(headers, path, session, auth)
@@ -299,102 +215,56 @@ async def proxy(
)
logger.debug("Processing unauthenticated GET request", extra={"path": path})
# Try fallback for GET requests too
last_exception = None
for i, upstream in enumerate(upstreams):
try:
headers = upstream.prepare_headers(dict(request.headers))
return await upstream.forward_get_request(request, path, headers)
except (httpx.TimeoutException, httpx.ConnectError) as e:
logger.warning(
f"Upstream GET provider {i + 1}/{len(upstreams)} ({upstream.provider_type}) timed out, trying fallback",
extra={"path": path, "error": str(e)},
)
last_exception = e
continue
error_message = "Upstream service request timed out"
if isinstance(last_exception, httpx.ConnectError):
error_message = "Unable to connect to upstream service"
return create_error_response(
"upstream_error", error_message, 502, request=request
)
headers = upstream.prepare_headers(dict(request.headers))
return await upstream.forward_get_request(request, path, headers)
if request_body_dict:
await pay_for_request(key, max_cost_for_model, session)
# Fallback for API Key payments
last_exception = None
for i, upstream in enumerate(upstreams):
try:
headers = upstream.prepare_headers(dict(request.headers))
if is_responses_api:
response = await upstream.forward_responses_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
else:
response = await upstream.forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
headers = upstream.prepare_headers(dict(request.headers))
if response.status_code != 200:
# If it's a 429 (rate limit) or 503 (service unavailable), we might also want to fallback
if response.status_code in (429, 503, 502) and i < len(upstreams) - 1:
logger.warning(
f"Upstream provider {i + 1}/{len(upstreams)} returned {response.status_code}, trying fallback",
extra={"model": model_id, "status": response.status_code},
)
continue
if is_responses_api:
response = await upstream.forward_responses_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
else:
response = await upstream.forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
await revert_pay_for_request(key, session, max_cost_for_model)
logger.warning(
"Upstream request failed, revert payment",
extra={
"status_code": response.status_code,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
"max_cost_for_model": max_cost_for_model,
"upstream_headers": response.headers
if hasattr(response, "headers")
else None,
},
)
return response
if response.status_code != 200:
await revert_pay_for_request(key, session, max_cost_for_model)
logger.warning(
"Upstream request failed, revert payment",
extra={
"status_code": response.status_code,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
"max_cost_for_model": max_cost_for_model,
"upstream_headers": response.headers
if hasattr(response, "headers")
else None,
},
)
# Return the mapped error response generated earlier rather than masking with 502
return response
return response
except (httpx.TimeoutException, httpx.ConnectError) as e:
logger.warning(
f"Upstream provider {i + 1}/{len(upstreams)} ({upstream.provider_type}) timed out, trying fallback",
extra={"model": model_id, "error": str(e)},
)
last_exception = e
continue
# All providers failed with timeout/connect error
await revert_pay_for_request(key, session, max_cost_for_model)
error_message = "Upstream service request timed out"
if isinstance(last_exception, httpx.ConnectError):
error_message = "Unable to connect to upstream service"
return create_error_response("upstream_error", error_message, 502, request=request)
return response
async def get_bearer_token_key(
+7 -21
View File
@@ -37,8 +37,6 @@ from ..wallet import recieve_token, send_token
logger = get_logger(__name__)
DEFAULT_PROXY_TIMEOUT = 30.0
class TopupData(BaseModel):
"""Universal top-up data schema for Lightning Network invoices."""
@@ -692,7 +690,6 @@ class BaseUpstreamProvider:
},
)
raise
except Exception as e:
logger.error(
"Error processing non-streaming chat completion",
@@ -1059,7 +1056,7 @@ class BaseUpstreamProvider:
client = httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=1),
timeout=DEFAULT_PROXY_TIMEOUT,
timeout=None,
)
try:
@@ -1202,8 +1199,7 @@ class BaseUpstreamProvider:
if isinstance(exc, httpx.ConnectError):
error_message = "Unable to connect to upstream service"
elif isinstance(exc, httpx.TimeoutException):
# Re-raise timeout exception to allow fallback handling in proxy layer
raise
error_message = "Upstream service request timed out"
elif isinstance(exc, httpx.NetworkError):
error_message = "Network error while connecting to upstream service"
else:
@@ -1286,7 +1282,7 @@ class BaseUpstreamProvider:
client = httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=1),
timeout=DEFAULT_PROXY_TIMEOUT,
timeout=None,
)
try:
@@ -1406,8 +1402,7 @@ class BaseUpstreamProvider:
if isinstance(exc, httpx.ConnectError):
error_message = "Unable to connect to upstream service"
elif isinstance(exc, httpx.TimeoutException):
# Re-raise timeout exception to allow fallback handling in proxy layer
raise
error_message = "Upstream service request timed out"
elif isinstance(exc, httpx.NetworkError):
error_message = "Network error while connecting to upstream service"
else:
@@ -1470,7 +1465,7 @@ class BaseUpstreamProvider:
async with httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=1),
timeout=DEFAULT_PROXY_TIMEOUT,
timeout=None,
) as client:
try:
response = await client.send(
@@ -1501,9 +1496,6 @@ class BaseUpstreamProvider:
status_code=response.status_code,
headers=dict(response.headers),
)
except (httpx.TimeoutException, httpx.ConnectError):
# Re-raise to allow fallback handling in proxy layer
raise
except Exception as exc:
tb = traceback.format_exc()
logger.error(
@@ -2036,7 +2028,7 @@ class BaseUpstreamProvider:
async with httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=1),
timeout=DEFAULT_PROXY_TIMEOUT,
timeout=None,
) as client:
try:
response = await client.send(
@@ -2130,9 +2122,6 @@ class BaseUpstreamProvider:
headers=dict(response.headers),
background=background_tasks,
)
except (httpx.TimeoutException, httpx.ConnectError):
# Re-raise to allow fallback handling in proxy layer
raise
except Exception as exc:
tb = traceback.format_exc()
logger.error(
@@ -2300,7 +2289,7 @@ class BaseUpstreamProvider:
async with httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=1),
timeout=DEFAULT_PROXY_TIMEOUT,
timeout=None,
) as client:
try:
response = await client.send(
@@ -2394,9 +2383,6 @@ class BaseUpstreamProvider:
headers=dict(response.headers),
background=background_tasks,
)
except (httpx.TimeoutException, httpx.ConnectError):
# Re-raise to allow fallback handling in proxy layer
raise
except Exception as exc:
tb = traceback.format_exc()
logger.error(
+4 -1
View File
@@ -218,7 +218,10 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
results = await asyncio.gather(*tasks)
upstreams = [p for p in results if p is not None]
if "https://testnut.cashu.space" in settings.cashu_mints:
if (
"https://testnut.cashu.space" in settings.cashu_mints
and not settings.disable_testnut_mock_upstream
):
from .fake import MockUpstreamProvider
mock_provider = MockUpstreamProvider("mock", "mock")
+4 -1
View File
@@ -313,7 +313,10 @@ async def periodic_payout() -> None:
try:
async with db.create_session() as session:
for mint_url in settings.cashu_mints:
if mint_url == "https://testnut.cashu.space":
if (
mint_url == "https://testnut.cashu.space"
and not settings.disable_testnut_mock_upstream
):
continue
for unit in ["sat", "msat"]:
wallet = await get_wallet(mint_url, unit)
+450 -2
View File
@@ -21,7 +21,17 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { CalendarIcon, Filter, X } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Checkbox } from '@/components/ui/checkbox';
import { CalendarIcon, Filter, X, Plus } from 'lucide-react';
import { useState, useEffect } from 'react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
@@ -31,11 +41,17 @@ interface LogFiltersProps {
selectedLevel: string;
requestId: string;
searchText: string;
selectedStatusCodes: string[];
selectedMethods: string[];
selectedEndpoints: string[];
limit: number;
onDateChange: (date: string) => void;
onLevelChange: (level: string) => void;
onRequestIdChange: (requestId: string) => void;
onSearchTextChange: (searchText: string) => void;
onStatusCodesChange: (statusCodes: string[]) => void;
onMethodsChange: (methods: string[]) => void;
onEndpointsChange: (endpoints: string[]) => void;
onLimitChange: (limit: number) => void;
onClearFilters: () => void;
}
@@ -43,16 +59,87 @@ interface LogFiltersProps {
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
const PRESET_LIMITS = ['25', '50', '100', '200', '500', '1000'];
const STATUS_CODE_OPTIONS = [
'200',
'201',
'204',
'400',
'401',
'402',
'403',
'404',
'422',
'429',
'500',
'502',
'503',
'504',
];
const METHOD_OPTIONS = [
'GET',
'POST',
'PUT',
'DELETE',
'PATCH',
'OPTIONS',
'HEAD',
];
const ENDPOINT_OPTIONS = [
'/chat/completions',
'/v1/chat/completions',
'/models',
'/v1/models',
'/responses',
'/v1/responses',
'v1/embeddings/models',
'/embeddings/models',
];
interface FilterBadgeProps {
value: string;
onRemove: (value: string) => void;
}
function FilterBadge({ value, onRemove }: FilterBadgeProps) {
return (
<Badge
variant='secondary'
className='flex items-center gap-1 px-1 font-normal'
>
{value}
<button
type='button'
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove(value);
}}
className='hover:bg-muted-foreground/20 rounded-full'
>
<X className='h-3 w-3' />
</button>
</Badge>
);
}
export function LogFilters({
selectedDate,
selectedLevel,
requestId,
searchText,
selectedStatusCodes,
selectedMethods,
selectedEndpoints,
limit,
onDateChange,
onLevelChange,
onRequestIdChange,
onSearchTextChange,
onStatusCodesChange,
onMethodsChange,
onEndpointsChange,
onLimitChange,
onClearFilters,
}: LogFiltersProps) {
@@ -68,6 +155,10 @@ export function LogFilters({
: undefined
);
const [statusSearch, setStatusSearch] = useState('');
const [methodSearch, setMethodSearch] = useState('');
const [endpointSearch, setEndpointSearch] = useState('');
useEffect(() => {
const currentIsPreset = PRESET_LIMITS.includes(limit.toString());
setIsCustom(!currentIsPreset);
@@ -129,6 +220,31 @@ export function LogFilters({
}
};
const toggleSelection = (
current: string[],
value: string,
onChange: (val: string[]) => void
) => {
if (current.includes(value)) {
onChange(current.filter((v) => v !== value));
} else {
onChange([...current, value]);
}
};
const handleQuickStatusCode = (range: '4xx' | '5xx') => {
const codes = STATUS_CODE_OPTIONS.filter((c) => c.startsWith(range[0]));
const newSelection = new Set([...selectedStatusCodes]);
const allIncluded = codes.every((c) => selectedStatusCodes.includes(c));
if (allIncluded) {
codes.forEach((c) => newSelection.delete(c));
} else {
codes.forEach((c) => newSelection.add(c));
}
onStatusCodesChange(Array.from(newSelection));
};
return (
<Card className='mb-6'>
<CardHeader>
@@ -137,7 +253,8 @@ export function LogFilters({
Filters
</CardTitle>
<CardDescription>
Filter logs by date, level, request ID, text search, and limit
Filter logs by date, level, request ID, text search, status code,
method, endpoint and limit
</CardDescription>
</CardHeader>
<CardContent>
@@ -197,6 +314,337 @@ export function LogFilters({
</Select>
</div>
<div className='space-y-2'>
<Label>Status Codes</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1'>
{selectedStatusCodes.length > 0 ? (
selectedStatusCodes.map((code) => (
<FilterBadge
key={code}
value={code}
onRemove={(val) =>
toggleSelection(
selectedStatusCodes,
val,
onStatusCodesChange
)
}
/>
))
) : (
<span className='text-muted-foreground'>All codes</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className='w-64 p-0' align='start'>
<Command>
<CommandInput
placeholder='Search or add status code...'
value={statusSearch}
onValueChange={setStatusSearch}
/>
<CommandList>
{selectedStatusCodes.length > 0 && (
<CommandGroup heading='Selected'>
{selectedStatusCodes.map((code) => (
<CommandItem
key={`selected-${code}`}
onSelect={() =>
toggleSelection(
selectedStatusCodes,
code,
onStatusCodesChange
)
}
>
<Checkbox checked={true} className='mr-2' />
{code}
</CommandItem>
))}
</CommandGroup>
)}
{statusSearch &&
!STATUS_CODE_OPTIONS.includes(statusSearch) &&
!selectedStatusCodes.includes(statusSearch) && (
<CommandGroup heading='Custom'>
<CommandItem
onSelect={() => {
if (/^\d+$/.test(statusSearch)) {
toggleSelection(
selectedStatusCodes,
statusSearch,
onStatusCodesChange
);
setStatusSearch('');
}
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{statusSearch}&quot;
</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 &quot;{methodSearch.toUpperCase()}&quot;
</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 &quot;{endpointSearch}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading='Common Endpoints'>
{ENDPOINT_OPTIONS.filter(
(endpoint) => !selectedEndpoints.includes(endpoint)
).map((endpoint) => (
<CommandItem
key={endpoint}
onSelect={() =>
toggleSelection(
selectedEndpoints,
endpoint,
onEndpointsChange
)
}
>
<Checkbox checked={false} className='mr-2' />
{endpoint}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className='space-y-2'>
<Label htmlFor='request-id'>Request ID</Label>
<Input
+84 -2
View File
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
@@ -22,15 +22,66 @@ import { LogFilters } from './log-filters';
import { LogEntryCard } from './log-entry-card';
import { LogDetailsDialog } from './log-details-dialog';
const STORAGE_KEY = 'routstr-log-filters';
export default function LogsPage() {
const [selectedDate, setSelectedDate] = useState<string>('all');
const [selectedLevel, setSelectedLevel] = useState<string>('all');
const [requestId, setRequestId] = useState<string>('');
const [searchText, setSearchText] = useState<string>('');
const [selectedStatusCodes, setSelectedStatusCodes] = useState<string[]>([]);
const [selectedMethods, setSelectedMethods] = useState<string[]>([]);
const [selectedEndpoints, setSelectedEndpoints] = useState<string[]>([]);
const [limit, setLimit] = useState<number>(100);
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
// Load filters from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.selectedDate) setSelectedDate(parsed.selectedDate);
if (parsed.selectedLevel) setSelectedLevel(parsed.selectedLevel);
if (parsed.requestId) setRequestId(parsed.requestId);
if (parsed.searchText) setSearchText(parsed.searchText);
if (parsed.selectedStatusCodes)
setSelectedStatusCodes(parsed.selectedStatusCodes);
if (parsed.selectedMethods) setSelectedMethods(parsed.selectedMethods);
if (parsed.selectedEndpoints)
setSelectedEndpoints(parsed.selectedEndpoints);
if (parsed.limit) setLimit(parsed.limit);
} catch (e) {
console.error('Failed to load filters from localStorage', e);
}
}
}, []);
// Save filters to localStorage whenever they change
useEffect(() => {
const filters = {
selectedDate,
selectedLevel,
requestId,
searchText,
selectedStatusCodes,
selectedMethods,
selectedEndpoints,
limit,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
}, [
selectedDate,
selectedLevel,
requestId,
searchText,
selectedStatusCodes,
selectedMethods,
selectedEndpoints,
limit,
]);
const {
data: logsData,
refetch: refetchLogs,
@@ -42,6 +93,9 @@ export default function LogsPage() {
selectedLevel,
requestId,
searchText,
selectedStatusCodes,
selectedMethods,
selectedEndpoints,
limit,
],
queryFn: () =>
@@ -50,6 +104,16 @@ export default function LogsPage() {
level: selectedLevel === 'all' ? undefined : selectedLevel,
request_id: requestId || undefined,
search: searchText || undefined,
status_codes:
selectedStatusCodes.length > 0
? selectedStatusCodes.join(',')
: undefined,
methods:
selectedMethods.length > 0 ? selectedMethods.join(',') : undefined,
endpoints:
selectedEndpoints.length > 0
? selectedEndpoints.join(',')
: undefined,
limit: limit,
}),
refetchInterval: 30000,
@@ -60,6 +124,9 @@ export default function LogsPage() {
setSelectedLevel('all');
setRequestId('');
setSearchText('');
setSelectedStatusCodes([]);
setSelectedMethods([]);
setSelectedEndpoints([]);
setLimit(100);
};
@@ -100,11 +167,17 @@ export default function LogsPage() {
selectedLevel={selectedLevel}
requestId={requestId}
searchText={searchText}
selectedStatusCodes={selectedStatusCodes}
selectedMethods={selectedMethods}
selectedEndpoints={selectedEndpoints}
limit={limit}
onDateChange={setSelectedDate}
onLevelChange={setSelectedLevel}
onRequestIdChange={setRequestId}
onSearchTextChange={setSearchText}
onStatusCodesChange={setSelectedStatusCodes}
onMethodsChange={setSelectedMethods}
onEndpointsChange={setSelectedEndpoints}
onLimitChange={setLimit}
onClearFilters={handleClearFilters}
/>
@@ -122,13 +195,22 @@ export default function LogsPage() {
{(selectedDate !== 'all' ||
selectedLevel !== 'all' ||
requestId ||
searchText) && (
searchText ||
selectedStatusCodes.length > 0 ||
selectedMethods.length > 0 ||
selectedEndpoints.length > 0) && (
<CardDescription className='text-xs sm:text-sm'>
Showing logs
{selectedDate !== 'all' && ` for ${selectedDate}`}
{selectedLevel !== 'all' && ` with level ${selectedLevel}`}
{requestId && ` with request ID ${requestId}`}
{searchText && ` matching "${searchText}"`}
{selectedStatusCodes.length > 0 &&
` with status ${selectedStatusCodes.join(', ')}`}
{selectedMethods.length > 0 &&
` with method ${selectedMethods.join(', ')}`}
{selectedEndpoints.length > 0 &&
` with endpoint ${selectedEndpoints.join(', ')}`}
</CardDescription>
)}
</CardHeader>
+3
View File
@@ -17,6 +17,9 @@ export interface LogsResponse {
level: string | null;
request_id: string | null;
search: string | null;
status_codes: string | null;
methods: string | null;
endpoints: string | null;
limit: number;
}