XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 1
返回提交历史

XFEstudio/gpt4free

feat: implement request logging middleware and logs inspector API

614d8a8c
hlohaus <983577+hlohaus@users.noreply.github.com>
提交于

代码差异

1 个文件 +292 -48
Modified g4f/api/__init__.py +292 -48
@@ -8,6 +8,7 @@ import os
8 8 import re
9 9 import shutil
10 10 import time
11 from collections import deque
11 12 from email.utils import formatdate
12 13 import os.path
13 14 import hashlib
@@ -73,6 +74,7 @@ from g4f.providers.response import AudioResponse
73 74 from g4f.providers.any_provider import AnyProvider
74 75 from g4f.providers.any_model_map import model_map, vision_models, image_models, audio_models, video_models
75 76 from g4f.config import AppConfig
77 from g4f.client import ClientFactory
76 78 from g4f import Provider
77 79 from g4f.Provider import ProviderUtils
78 80
@@ -94,6 +96,164 @@ except ImportError:
94 96
95 97 logger = logging.getLogger(__name__)
96 98
99 # ---------------------------------------------------------------------------
100 # Request / response log store
101 # ---------------------------------------------------------------------------
102
103 _MAX_LOG_ENTRIES = 1000
104 _MAX_BODY_LOG_SIZE = 1024 * 1024 # 1 MB
105 _SENSITIVE_HEADERS = {"authorization", "g4f-api-key", "cookie", "set-cookie", "x-api-key"}
106
107 _request_log: deque = deque(maxlen=_MAX_LOG_ENTRIES)
108 _log_id_counter: int = 0
109
110
111 def _sanitize_headers(headers: dict) -> dict:
112 return {k: ("***" if k.lower() in _SENSITIVE_HEADERS else v) for k, v in headers.items()}
113
114
115 def _try_parse_body(body_bytes: bytes, content_type: str):
116 if not body_bytes:
117 return None
118 if len(body_bytes) > _MAX_BODY_LOG_SIZE:
119 return f"<{len(body_bytes)} bytes – truncated>"
120 if "application/json" in content_type:
121 try:
122 return json.loads(body_bytes)
123 except Exception:
124 pass
125 try:
126 return body_bytes.decode("utf-8", errors="replace")
127 except Exception:
128 return f"<binary {len(body_bytes)} bytes>"
129
130
131 _LOGS_HTML = '''<!doctype html>
132 <html lang="en">
133 <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
134 <title>g4f – Request Log</title>
135 <style>
136 *{box-sizing:border-box;margin:0;padding:0}
137 body{font-family:system-ui,-apple-system,sans-serif;background:#0d1117;color:#c9d1d9;min-height:100vh;font-size:14px}
138 a{color:#58a6ff}
139 .header{padding:14px 20px;border-bottom:1px solid #21262d;display:flex;align-items:center;gap:14px}
140 .header h1{font-size:15px;font-weight:600;color:#f0f6fc}
141 .header .sub{font-size:12px;color:#8b949e}
142 .toolbar{padding:10px 20px;border-bottom:1px solid #21262d;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
143 .toolbar input[type=text]{flex:1;min-width:180px;background:#161b22;border:1px solid #30363d;color:#c9d1d9;padding:5px 10px;border-radius:6px;font-size:13px;outline:none}
144 .toolbar input[type=text]:focus{border-color:#58a6ff}
145 .toolbar label{display:flex;align-items:center;gap:5px;font-size:13px;color:#8b949e;cursor:pointer;user-select:none}
146 .btn{padding:5px 14px;border-radius:6px;border:1px solid #30363d;cursor:pointer;font-size:13px;background:#21262d;color:#c9d1d9}
147 .btn:hover{background:#30363d}
148 .btn-danger{border-color:#6e3435;background:#1c1214;color:#ffa198}
149 .btn-danger:hover{background:#6e3435}
150 .meta{margin-left:auto;font-size:12px;color:#6e7681}
151 .table-wrap{overflow-x:auto;padding:0 20px 40px}
152 table{width:100%;border-collapse:collapse;margin-top:14px;font-size:13px}
153 th{padding:6px 8px;text-align:left;color:#8b949e;font-weight:500;border-bottom:1px solid #21262d;white-space:nowrap}
154 td{padding:6px 8px;border-bottom:1px solid #161b22;white-space:nowrap;max-width:320px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}
155 tbody tr{cursor:pointer}
156 tbody tr:hover td{background:#161b22}
157 .GET{color:#3fb950}.POST{color:#58a6ff}.PUT{color:#e3b341}.DELETE{color:#f85149}.PATCH{color:#d2a8ff}
158 .s2{color:#3fb950}.s3{color:#58a6ff}.s4{color:#e3b341}.s5{color:#f85149}
159 .tag{display:inline-block;font-size:10px;padding:1px 6px;border-radius:10px;font-weight:500}
160 .tag-sse{background:#0d2044;color:#79c0ff}
161 .tag-body{background:#0d2820;color:#56d364}
162 .tag-empty{background:#1c2128;color:#6e7681}
163 .overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.65);z-index:200;padding:24px;align-items:flex-start;justify-content:center;overflow-y:auto}
164 .overlay.active{display:flex}
165 .modal{background:#161b22;border:1px solid #30363d;border-radius:10px;width:100%;max-width:1040px;display:flex;flex-direction:column}
166 .modal-head{padding:14px 18px;border-bottom:1px solid #21262d;display:flex;justify-content:space-between;align-items:center;gap:10px}
167 .modal-head h2{font-size:13px;font-weight:600;color:#f0f6fc;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,monospace}
168 .modal-close{background:none;border:none;color:#6e7681;font-size:18px;cursor:pointer;line-height:1;padding:2px 6px;flex-shrink:0}
169 .modal-close:hover{color:#c9d1d9}
170 .modal-grid{display:grid;grid-template-columns:1fr 1fr}
171 .panel{padding:16px 18px;display:flex;flex-direction:column;gap:8px}
172 .panel:first-child{border-right:1px solid #21262d}
173 .panel-title{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6e7681}
174 .panel pre{background:#0d1117;border:1px solid #21262d;border-radius:6px;padding:12px;font-size:12px;line-height:1.5;overflow:auto;max-height:440px;white-space:pre-wrap;word-break:break-all;color:#c9d1d9;margin:0;font-family:ui-monospace,monospace}
175 @media(max-width:640px){.modal-grid{grid-template-columns:1fr}.panel:first-child{border-right:none;border-bottom:1px solid #21262d}}
176 </style>
177 </head>
178 <body>
179 <div class="header">
180 <h1>g4f Request Log</h1>
181 <span class="sub">last 500 entries &middot; <a href="/v1">/v1 API</a></span>
182 </div>
183 <div class="toolbar">
184 <input type="text" id="q" placeholder="Filter by path, method, status, user&hellip;" oninput="render()">
185 <label><input type="checkbox" id="auto" checked onchange="toggleAuto()"> Auto&#8209;refresh&nbsp;(3s)</label>
186 <button class="btn btn-danger" onclick="clearLogs()">Clear</button>
187 <span class="meta" id="meta"></span>
188 </div>
189 <div class="table-wrap"><table>
190 <thead><tr>
191 <th>#</th><th>Time (UTC)</th><th>Method</th><th>Path</th>
192 <th>Status</th><th>ms</th><th>User</th><th>Body</th>
193 </tr></thead>
194 <tbody id="tb"></tbody>
195 </table></div>
196 <div class="overlay" id="ov" onclick="overlayClick(event)">
197 <div class="modal" id="mod">
198 <div class="modal-head">
199 <h2 id="mtitle">&ndash;</h2>
200 <button class="modal-close" onclick="closeModal()">&#x2715;</button>
201 </div>
202 <div class="modal-grid">
203 <div class="panel"><div class="panel-title">Request</div><pre id="preq"></pre></div>
204 <div class="panel"><div class="panel-title">Response</div><pre id="pres"></pre></div>
205 </div>
206 </div>
207 </div>
208 <script>
209 'use strict';
210 var all=[], timer=null;
211 function sc(s){return s>=500?'s5':s>=400?'s4':s>=300?'s3':'s2';}
212 function btag(e){if(e.streaming&&e.response_body==null)return\'<span class="tag tag-sse">SSE&hellip;</span>\';if(e.response_body!=null)return\'<span class="tag tag-body">\'+(e.streaming?\'SSE\':\'body\')+\'</span>\';return\'<span class="tag tag-empty">&ndash;</span>\';}
213 function esc(s){return String(s??\'\'). replace(/&/g,\'&amp;\').replace(/</g,\'&lt;\').replace(/>/g,\'&gt;\');}
214 function fmt(v){if(v==null)return\'(empty)\';if(typeof v===\'object\')return JSON.stringify(v,null,2);return String(v);}
215 async function load(){
216 try{var r=await fetch(\'/api/logs?limit=500\');if(!r.ok)return;var d=await r.json();all=d.entries||[];render();}catch(e){}
217 }
218 function render(){
219 var q=document.getElementById(\'q\').value.trim().toLowerCase();
220 var rows=q?all.filter(function(e){return(e.method+\' \'+e.path+\' \'+e.status+\' \'+(e.user||\'\')).toLowerCase().includes(q);}):all;
221 document.getElementById(\'meta\').textContent=rows.length+\' / \'+all.length+\' entries\';
222 document.getElementById(\'tb\').innerHTML=rows.map(function(e){
223 var t=(e.timestamp||\'\').replace(\'T\',\' \').replace(\'Z\',\'\');
224 var p=esc(e.path+(e.query?\'?\'+e.query:\'\'));
225 return\'<tr onclick="detail(\'+e.id+\')">\'+
226 \'<td style="color:#484f58">\'+e.id+\'</td>\'+
227 \'<td style="color:#6e7681;font-size:12px">\'+esc(t)+\'</td>\'+
228 \'<td class="\'+esc(e.method)+\'">\'+esc(e.method)+\'</td>\'+
229 \'<td title="\'+p+\'">\'+p+\'</td>\'+
230 \'<td class="\'+sc(e.status)+\'">\'+e.status+\'</td>\'+
231 \'<td style="color:#8b949e">\'+e.duration_ms+\'</td>\'+
232 \'<td style="color:#6e7681">\'+esc(e.user||\'\')+\'</td>\'+
233 \'<td>\'+btag(e)+\'</td>\'+
234 \'</tr>\';
235 }).join(\'\');
236 }
237 function detail(id){
238 var e=all.find(function(x){return x.id===id;});
239 if(!e)return;
240 document.getElementById(\'mtitle\').textContent=\'#\'+e.id+\' \'+e.method+\' \'+e.path+(e.query?\'?\'+e.query:\'\')+\' \u2192 \'+e.status+\' (\'+e.duration_ms+\'ms)\';
241 var req=\'\';
242 if(e.request_headers){req+=\'Headers:\\n\';for(var k in e.request_headers)req+=\' \'+k+\': \'+e.request_headers[k]+\'\\n\';}
243 if(e.request_body!=null)req+=\'\\nBody:\\n\'+fmt(e.request_body);
244 document.getElementById(\'preq\').textContent=req||\'(none)\';
245 document.getElementById(\'pres\').textContent=e.response_body!=null?fmt(e.response_body):(e.streaming?\'(streaming – collecting…)\':\'(empty)\');
246 document.getElementById(\'ov\').classList.add(\'active\');
247 }
248 function closeModal(){document.getElementById(\'ov\').classList.remove(\'active\');}
249 function overlayClick(ev){if(ev.target===document.getElementById(\'ov\'))closeModal();}
250 document.addEventListener(\'keydown\',function(e){if(e.key===\'Escape\')closeModal();});
251 async function clearLogs(){await fetch(\'/api/logs\',{method:\'DELETE\'});all=[];render();}
252 function toggleAuto(){clearInterval(timer);timer=null;if(document.getElementById(\'auto\').checked)timer=setInterval(load,3000);}
253 load();timer=setInterval(load,3000);
254 </script>
255 </body></html>'''
256
97 257 @asynccontextmanager
98 258 async def lifespan(app: FastAPI):
99 259 # Read cookie files if not ignored
@@ -113,6 +273,9 @@ async def lifespan(app: FastAPI):
113 273 except Exception as e:
114 274 debug.error(f"Failed to remove lock file {lock_file}:", e)
115 275
276 _LOG_SKIP_PREFIXES = ("/images/", "/media/", "/thumbnail/", "/dist/", "/.well-known/")
277 _LOG_SKIP_EXACT = {"/api/logs", "/logs", "/favicon.ico"}
278
116 279 def create_app():
117 280 app = FastAPI(lifespan=lifespan)
118 281
@@ -126,6 +289,89 @@ def create_app():
126 289 expose_headers=["*"],
127 290 )
128 291
292 @app.middleware("http")
293 async def log_requests(request: Request, call_next):
294 if AppConfig.demo:
295 return await call_next(request)
296 global _log_id_counter
297 path = request.url.path
298 if any(path.startswith(p) for p in _LOG_SKIP_PREFIXES) or path in _LOG_SKIP_EXACT:
299 return await call_next(request)
300
301 qs = f"?{request.url.query}" if request.url.query else ""
302 user = request.headers.get("x-user", "")
303 user_info = f" user={user}" if user else ""
304 logger.debug("→ %s %s%s%s", request.method, path, qs, user_info)
305
306 # Capture request body (Starlette caches after first read)
307 req_body_bytes = await request.body()
308 req_body = _try_parse_body(req_body_bytes, request.headers.get("content-type", ""))
309
310 start = time.monotonic()
311 response = await call_next(request)
312 duration_ms = round((time.monotonic() - start) * 1000)
313
314 resp_content_type = response.headers.get("content-type", "")
315 is_streaming = "text/event-stream" in resp_content_type
316 log_entry: dict = {}
317
318 if not is_streaming:
319 chunks: list[bytes] = []
320 async for chunk in response.body_iterator:
321 chunks.append(chunk)
322 resp_body_bytes = b"".join(chunks)
323 resp_body = _try_parse_body(resp_body_bytes, resp_content_type)
324 # Reconstruct response so it can still be sent to the client
325 resp_headers = {k: v for k, v in response.headers.items() if k.lower() != "content-length"}
326 response = Response(
327 content=resp_body_bytes,
328 status_code=response.status_code,
329 headers=resp_headers,
330 media_type=response.media_type,
331 )
332 else:
333 # Tee the streaming iterator: forward chunks to client AND accumulate for log
334 sse_chunks: list[bytes] = []
335 resp_body = None
336 orig_iterator = response.body_iterator
337
338 async def tee_iterator():
339 async for chunk in orig_iterator:
340 if isinstance(chunk, bytes):
341 sse_chunks.append(chunk)
342 else:
343 sse_chunks.append(chunk.encode("utf-8", errors="replace"))
344 yield chunk
345 # After iteration completes, parse and store the full SSE body
346 raw = b"".join(sse_chunks)
347 parsed = _try_parse_body(raw, "text/plain")
348 log_entry["response_body"] = parsed
349
350 response.body_iterator = tee_iterator()
351
352 level = logging.WARNING if response.status_code >= 400 else logging.INFO
353 logger.log(level, "%s %s%s → %d (%dms)%s",
354 request.method, path, qs, response.status_code, duration_ms, user_info)
355
356 _log_id_counter += 1
357 log_entry.update({
358 "id": _log_id_counter,
359 "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
360 "method": request.method,
361 "path": path,
362 "query": request.url.query or None,
363 "status": response.status_code,
364 "duration_ms": duration_ms,
365 "user": user or None,
366 "streaming": is_streaming,
367 "request_headers": _sanitize_headers(dict(request.headers)),
368 "request_body": req_body,
369 "response_body": resp_body, # None for SSE until iterator completes
370 })
371 _request_log.append(log_entry)
372
373 return response
374
129 375 api = Api(app)
130 376
131 377 api.register_routes()
@@ -188,6 +434,15 @@ def update_headers(request: Request, new_api_key: str = None, user: str = None)
188 434 delattr(request, "_headers")
189 435 return request
190 436
437 def get_provider_by_label(provider: str) -> ProviderType:
438 try:
439 return ProviderUtils.get_by_label(provider)
440 except ValueError as e:
441 try:
442 return ClientFactory.create_provider(None, provider)
443 except ProviderNotFoundError:
444 raise e
445
191 446 class Api:
192 447 def __init__(self, app: FastAPI) -> None:
193 448 self.app = app
@@ -367,23 +622,8 @@ class Api:
367 622 })
368 623 async def models(provider: str, credentials: Annotated[HTTPAuthorizationCredentials, Depends(Api.security)] = None):
369 624 try:
370 provider = ProviderUtils.get_by_label(provider)
625 provider = get_provider_by_label(provider)
371 626 except ValueError as e:
372 if provider in model_map:
373 return {
374 "object": "list",
375 "data": [{
376 "id": provider,
377 "object": "model",
378 "created": 0,
379 "owned_by": provider,
380 "image": provider in image_models,
381 "vision": provider in vision_models,
382 "audio": provider in audio_models,
383 "video": provider in video_models,
384 "type": "image" if provider in image_models else "chat"
385 }]
386 }
387 627 return ErrorResponse.from_message(str(e), 404)
388 628 if not hasattr(provider, "get_models"):
389 629 models = []
@@ -412,7 +652,7 @@ class Api:
412 652 @self.app.get("/api/{provider}/quota")
413 653 async def provider_quota(provider: str, credentials: Annotated[HTTPAuthorizationCredentials, Depends(Api.security)] = None):
414 654 try:
415 provider = ProviderUtils.get_by_label(provider)
655 provider = get_provider_by_label(provider)
416 656 except ValueError as e:
417 657 return ErrorResponse.from_message(str(e), 404)
418 658 if not hasattr(provider, "get_quota"):
@@ -465,20 +705,15 @@ class Api:
465 705 conversation_id: str = None,
466 706 x_user: Annotated[str | None, Header()] = None,
467 707 ):
468 if provider is None:
469 provider = config.provider
470 if provider is None:
471 provider = AppConfig.provider
708 if provider is not None:
709 config.provider = provider
710 if config.provider is None:
711 config.provider = AppConfig.provider
472 712 try:
473 provider = ProviderUtils.get_by_label(provider).__name__
713 provider = get_provider_by_label(config.provider)
474 714 except ValueError as e:
475 if provider in model_map:
476 config.model = provider
477 provider = None
478 elif provider is not None:
479 return ErrorResponse.from_message(str(e), 404)
715 return ErrorResponse.from_message(str(e), 404)
480 716 try:
481 config.provider = provider
482 717 if config.conversation_id is None:
483 718 config.conversation_id = conversation_id
484 719 if config.timeout is None:
@@ -520,6 +755,7 @@ class Api:
520 755 "proxy": AppConfig.proxy,
521 756 **(config.model_dump(exclude_none=True) if hasattr(config, "model_dump") else config.dict(exclude_none=True)),
522 757 **{
758 "provider": provider,
523 759 "conversation_id": None,
524 760 "conversation": conversation,
525 761 "user": x_user,
@@ -588,19 +824,17 @@ class Api:
588 824 if provider is None:
589 825 provider = AppConfig.provider
590 826 try:
591 provider = ProviderUtils.get_by_label(provider)
827 provider = get_provider_by_label(provider)
592 828 except ValueError as e:
593 if provider in model_map:
594 config.model = provider
595 provider = None
596 elif provider is not None:
597 return ErrorResponse.from_message(str(e), 404)
598 config.provider = provider
829 return ErrorResponse.from_message(str(e), 404)
599 830 if config.api_key is None and credentials is not None and credentials.credentials != "secret":
600 831 config.api_key = credentials.credentials
601 832 try:
602 833 response = await self.client.images.generate(
603 **config.dict(exclude_none=True),
834 **config.model_dump(exclude_none=True)
835 if hasattr(config, "model_dump")
836 else config.dict(exclude_none=True),
837 provider=provider
604 838 )
605 839 for image in response.data:
606 840 if hasattr(image, "url") and image.url.startswith("/"):
@@ -634,7 +868,7 @@ class Api:
634 868 })
635 869 async def providers_info(provider: str):
636 870 try:
637 provider = ProviderUtils.get_by_label(provider)
871 provider = get_provider_by_label(provider)
638 872 except ValueError as e:
639 873 return ErrorResponse.from_message(str(e), 404)
640 874 def safe_get_models(provider: ProviderType) -> list[str]:
@@ -941,18 +1175,10 @@ class Api:
941 1175 if provider is None:
942 1176 provider = "MarkItDown"
943 1177 try:
944 provider = ProviderUtils.get_by_label(provider)
1178 provider = get_provider_by_label(provider)
945 1179 except ValueError as e:
946 if provider in model_map:
947 model = provider
948 provider = None
949 else:
950 return ErrorResponse.from_message(str(e), 404)
1180 return ErrorResponse.from_message(str(e), 404)
951 1181 kwargs = {"modalities": ["text"]}
952 if provider == "MarkItDown":
953 kwargs = {
954 "llm_client": self.client,
955 }
956 1182 try:
957 1183 response = await self.client.chat.completions.create(
958 1184 messages=prompt,
@@ -993,7 +1219,7 @@ class Api:
993 1219 if provider is None:
994 1220 provider = AppConfig.media_provider
995 1221 try:
996 provider = ProviderUtils.get_by_label(provider)
1222 provider = get_provider_by_label(provider)
997 1223 except ValueError as e:
998 1224 return ErrorResponse.from_message(str(e), 404)
999 1225 try:
@@ -1158,6 +1384,24 @@ class Api:
1158 1384 async def get_media_thumbnail(filename: str, request: Request):
1159 1385 return await get_media(filename, request, True)
1160 1386
1387 @self.app.get("/logs", response_class=HTMLResponse)
1388 async def logs_inspector():
1389 return HTMLResponse(_LOGS_HTML)
1390
1391 @self.app.get("/api/logs")
1392 async def get_logs(limit: int = 500, offset: int = 0):
1393 entries = list(_request_log)
1394 total = len(entries)
1395 start = max(0, total - limit - offset)
1396 end = total - offset if offset < total else total
1397 page = list(reversed(entries[start:end]))
1398 return {"total": total, "entries": page}
1399
1400 @self.app.delete("/api/logs")
1401 async def clear_logs():
1402 _request_log.clear()
1403 return {"status": "cleared"}
1404
1161 1405 def format_exception(e: Union[Exception, str], config: Union[ChatCompletionsConfig, ImageGenerationConfig] = None, image: bool = False) -> str:
1162 1406 provider = (AppConfig.media_provider if image else AppConfig.provider)
1163 1407 model = AppConfig.model