From bdf0e2c192a2b94f49c59cacc72d32eddb4f24dd Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sat, 3 Jan 2026 22:49:35 +0100 Subject: [PATCH] #282 more filter options --- routstr/core/admin.py | 22 ++ routstr/core/log_manager.py | 44 ++- routstr/payment/cost_calculation.py | 12 +- routstr/proxy.py | 2 +- routstr/upstream/base.py | 24 +- ui/app/logs/log-filters.tsx | 452 +++++++++++++++++++++++++++- ui/app/logs/page.tsx | 86 +++++- ui/app/logs/types.ts | 3 + 8 files changed, 631 insertions(+), 14 deletions(-) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 5fbfa444..d4f7ec59 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -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, } diff --git a/routstr/core/log_manager.py b/routstr/core/log_manager.py index 5052f3d2..43920605 100644 --- a/routstr/core/log_manager.py +++ b/routstr/core/log_manager.py @@ -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() diff --git a/routstr/payment/cost_calculation.py b/routstr/payment/cost_calculation.py index 2a3f9e8a..78ba5896 100644 --- a/routstr/payment/cost_calculation.py +++ b/routstr/payment/cost_calculation.py @@ -192,8 +192,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) diff --git a/routstr/proxy.py b/routstr/proxy.py index f11674aa..1d5aaa96 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -357,7 +357,7 @@ def extract_model_from_responses_request(request_body_dict: dict[str, Any]) -> s logger.warning( "No model found in Responses API request", - extra={"body_keys": list(request_body_dict.keys())} + extra={"body_keys": list(request_body_dict.keys())}, ) return "unknown" diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 69ef48d9..963f2c80 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -234,7 +234,11 @@ class BaseUpstreamProvider: ) # Handle model in input field (alternative format) - if "input" in data and isinstance(data["input"], dict) and "model" in data["input"]: + if ( + "input" in data + and isinstance(data["input"], dict) + and "model" in data["input"] + ): original_model = model_obj.id transformed_model = self.transform_model_name(original_model) data["input"]["model"] = transformed_model @@ -779,8 +783,13 @@ class BaseUpstreamProvider: # Track reasoning tokens for Responses API if usage := obj.get("usage", {}): - if isinstance(usage, dict) and "reasoning_tokens" in usage: - reasoning_tokens += usage.get("reasoning_tokens", 0) + if ( + isinstance(usage, dict) + and "reasoning_tokens" in usage + ): + reasoning_tokens += usage.get( + "reasoning_tokens", 0 + ) except json.JSONDecodeError: pass except Exception: @@ -933,8 +942,8 @@ class BaseUpstreamProvider: "model": response_json.get("model", "unknown"), "has_usage": "usage" in response_json, "has_reasoning_tokens": "usage" in response_json - and isinstance(response_json.get("usage"), dict) - and "reasoning_tokens" in response_json["usage"], + and isinstance(response_json.get("usage"), dict) + and "reasoning_tokens" in response_json["usage"], }, ) @@ -2503,7 +2512,10 @@ class BaseUpstreamProvider: usage_data = data_json["usage"] model = data_json.get("model") # Track reasoning tokens for Responses API - if isinstance(usage_data, dict) and "reasoning_tokens" in usage_data: + if ( + isinstance(usage_data, dict) + and "reasoning_tokens" in usage_data + ): reasoning_tokens = usage_data.get("reasoning_tokens", 0) elif "model" in data_json and not model: model = data_json["model"] diff --git a/ui/app/logs/log-filters.tsx b/ui/app/logs/log-filters.tsx index 81b9aff9..b72cd73a 100644 --- a/ui/app/logs/log-filters.tsx +++ b/ui/app/logs/log-filters.tsx @@ -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 ( + + {value} + + + ); +} + 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 ( @@ -137,7 +253,8 @@ export function LogFilters({ Filters - 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 @@ -197,6 +314,337 @@ export function LogFilters({ +
+ + + + + + + + + + {selectedStatusCodes.length > 0 && ( + + {selectedStatusCodes.map((code) => ( + + toggleSelection( + selectedStatusCodes, + code, + onStatusCodesChange + ) + } + > + + {code} + + ))} + + )} + {statusSearch && + !STATUS_CODE_OPTIONS.includes(statusSearch) && + !selectedStatusCodes.includes(statusSearch) && ( + + { + if (/^\d+$/.test(statusSearch)) { + toggleSelection( + selectedStatusCodes, + statusSearch, + onStatusCodesChange + ); + setStatusSearch(''); + } + }} + > + + Add "{statusSearch}" + + + )} + No results found. + + handleQuickStatusCode('4xx')} + > + + c.startsWith('4') + ).every((c) => selectedStatusCodes.includes(c))} + className='mr-2' + /> + 4xx Errors + + handleQuickStatusCode('5xx')} + > + + c.startsWith('5') + ).every((c) => selectedStatusCodes.includes(c))} + className='mr-2' + /> + 5xx Errors + + + + {STATUS_CODE_OPTIONS.filter( + (code) => !selectedStatusCodes.includes(code) + ).map((code) => ( + + toggleSelection( + selectedStatusCodes, + code, + onStatusCodesChange + ) + } + > + + {code} + + ))} + + + + + +
+ +
+ + + + + + + + + + {selectedMethods.length > 0 && ( + + {selectedMethods.map((method) => ( + + toggleSelection( + selectedMethods, + method, + onMethodsChange + ) + } + > + + {method} + + ))} + + )} + {methodSearch && + !METHOD_OPTIONS.includes(methodSearch.toUpperCase()) && + !selectedMethods.includes(methodSearch.toUpperCase()) && ( + + { + toggleSelection( + selectedMethods, + methodSearch.toUpperCase(), + onMethodsChange + ); + setMethodSearch(''); + }} + > + + Add "{methodSearch.toUpperCase()}" + + + )} + No results found. + + {METHOD_OPTIONS.filter( + (method) => !selectedMethods.includes(method) + ).map((method) => ( + + toggleSelection( + selectedMethods, + method, + onMethodsChange + ) + } + > + + {method} + + ))} + + + + + +
+ +
+ + + + + + + + + + {selectedEndpoints.length > 0 && ( + + {selectedEndpoints.map((endpoint) => ( + + toggleSelection( + selectedEndpoints, + endpoint, + onEndpointsChange + ) + } + > + + {endpoint} + + ))} + + )} + {endpointSearch && + !ENDPOINT_OPTIONS.includes(endpointSearch) && + !selectedEndpoints.includes(endpointSearch) && ( + + { + toggleSelection( + selectedEndpoints, + endpointSearch, + onEndpointsChange + ); + setEndpointSearch(''); + }} + > + + Add "{endpointSearch}" + + + )} + No results found. + + {ENDPOINT_OPTIONS.filter( + (endpoint) => !selectedEndpoints.includes(endpoint) + ).map((endpoint) => ( + + toggleSelection( + selectedEndpoints, + endpoint, + onEndpointsChange + ) + } + > + + {endpoint} + + ))} + + + + + +
+
('all'); const [selectedLevel, setSelectedLevel] = useState('all'); const [requestId, setRequestId] = useState(''); const [searchText, setSearchText] = useState(''); + const [selectedStatusCodes, setSelectedStatusCodes] = useState([]); + const [selectedMethods, setSelectedMethods] = useState([]); + const [selectedEndpoints, setSelectedEndpoints] = useState([]); const [limit, setLimit] = useState(100); const [selectedLog, setSelectedLog] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(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) && ( 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(', ')}`} )} diff --git a/ui/app/logs/types.ts b/ui/app/logs/types.ts index 8c905827..a9aa8f33 100644 --- a/ui/app/logs/types.ts +++ b/ui/app/logs/types.ts @@ -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; }