返回提交历史
Modified
g4f/api/__init__.py
+292
-48
XFEstudio/gpt4free
feat: implement request logging middleware and logs inspector API
614d8a8c
代码差异
1 个文件
+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 · <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…" oninput="render()">
185
<label><input type="checkbox" id="auto" checked onchange="toggleAuto()"> Auto‑refresh (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">–</h2>
200
<button class="modal-close" onclick="closeModal()">✕</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…</span>\';if(e.response_body!=null)return\'<span class="tag tag-body">\'+(e.streaming?\'SSE\':\'body\')+\'</span>\';return\'<span class="tag tag-empty">–</span>\';}
213
function esc(s){return String(s??\'\'). replace(/&/g,\'&\').replace(/</g,\'<\').replace(/>/g,\'>\');}
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