topoexploit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
topogpt3/api_server.py ADDED
@@ -0,0 +1,1178 @@
1
+ """OpenAI-compatible HTTP API server so TopoGPT3 can be used as a backend
2
+ for coding agents (e.g. Pi, Aider, Continue, Codex CLI, etc.).
3
+
4
+ Security Posture
5
+ ----------------
6
+ - **Authentication**: Bearer token (``Authorization: Bearer <key>``).
7
+ Keys are loaded from ``--keys`` (comma-separated) or the
8
+ ``TOPOGPT3_API_KEYS`` env var. Admin keys (prefixed ``admin:``) get
9
+ higher rate limits. Constant-time comparison prevents timing leaks.
10
+ - **Authorization**: token-bucket rate limiter per-key and per-IP with
11
+ configurable thresholds. After ``max_failures`` bad auth attempts an IP
12
+ is banned for ``ban_window`` seconds.
13
+ - **Input hardening**: Pydantic schemas enforce strict types, min/max
14
+ bounds, and length limits. Request body is capped server-side. Error
15
+ responses never leak stack traces.
16
+ - **Headers**: ``X-Content-Type-Options: nosniff``,
17
+ ``X-Frame-Options: DENY``, ``X-XSS-Protection: 1; mode=block``,
18
+ ``Content-Security-Policy: default-src 'none'`` on every response.
19
+ CORS policy allows nothing by default (configurable allow-origins).
20
+ - **Audit**: structured JSON log lines for every request (truncated
21
+ bodies, no secrets).
22
+
23
+ Usage::
24
+
25
+ TOPOGPT3_API_KEYS="sk-secret-key,admin:sk-admin-key" \\
26
+ python -m topogpt3 api_server \\
27
+ --checkpoint checkpoints_topogpt3/last \\
28
+ --port 8800
29
+
30
+ Pi / agent config::
31
+
32
+ export PI_BASE_URL=http://localhost:8800/v1
33
+ export PI_API_KEY=sk-secret-key
34
+
35
+ LazyOwn red-team checklist
36
+ --------------------------
37
+ - [ ] Auth bypass via missing Bearer header … 401
38
+ - [ ] Auth bypass via empty token … 401
39
+ - [ ] Auth bypass via timing attack on key comparison … prevented (secrets.compare_digest)
40
+ - [ ] Rate-limit bypass via spoofed X-Forwarded-For … rate-limited by real client IP
41
+ - [ ] Prompt injection to execute shell commands … impossible (no exec paths)
42
+ - [ ] TOCTOU on key file … keys read once at startup
43
+ - [ ] SSRF via the API … no outbound HTTP in inference path
44
+ - [ ] DoS via huge prompts … ``max_prompt_tokens`` cap
45
+ - [ ] DoS via connection exhaustion … uvicorn ``limit_concurrency``
46
+ - [ ] Information leak in error messages … ``include_in_schema=False`` on internal endpoints
47
+ - [ ] JSON injection in streaming chunks … validated via stdlib json
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import argparse
53
+ import hashlib
54
+ import hmac
55
+ import json
56
+ import logging
57
+ import os
58
+ import re
59
+ import secrets
60
+ import sys
61
+ import time
62
+ from collections import defaultdict
63
+ from contextlib import asynccontextmanager, contextmanager
64
+ from dataclasses import dataclass, field
65
+ from pathlib import Path
66
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
67
+
68
+ import torch
69
+
70
+ try:
71
+ from fastapi import FastAPI, HTTPException, Request, Response
72
+ from fastapi.middleware.cors import CORSMiddleware
73
+ from fastapi.middleware.gzip import GZipMiddleware
74
+ from fastapi.responses import JSONResponse, StreamingResponse
75
+ from pydantic import BaseModel, Field, field_validator
76
+ import uvicorn
77
+ except ImportError:
78
+ sys.exit(
79
+ "Install the api extras: pip install 'topogpt3[api]' "
80
+ "(fastapi + uvicorn + pydantic)"
81
+ )
82
+
83
+ from .model import BPETokenizer, TopoGPT2, TopoGPT2Config, set_seed
84
+ from .structured_output import (
85
+ GrammarConstraint,
86
+ StructuredOutputError,
87
+ constraint_from_request,
88
+ )
89
+ from safetensors.torch import load_file
90
+
91
+
92
+ # ═══════════════════════════════════════════════════════════════════════════
93
+ # Security constants
94
+ # ═══════════════════════════════════════════════════════════════════════════
95
+
96
+ MAX_REQUEST_BODY = 256 * 1024 # 256 KB
97
+ MAX_PROMPT_CHARS = 64 * 1024
98
+ MAX_MESSAGES = 200
99
+ MAX_STOP_STRINGS = 10
100
+ MAX_STOP_STRING_LEN = 128
101
+ MAX_CONTENT_LEN = 32 * 1024 # per message content field
102
+ MAX_OUTPUT_TOKENS = 4096
103
+ MAX_TEMPERATURE = 2.0
104
+ BAN_WINDOW_S = 3600 # 1 hour IP ban
105
+ MAX_AUTH_FAILURES = 10 # per IP within window
106
+ MAX_CONCURRENT_REQUESTS = 8
107
+
108
+ # Rate limits (tokens per second, bucket capacity)
109
+ RATE_LIMIT_USER_RPS = 10.0
110
+ RATE_LIMIT_ADMIN_RPS = 50.0
111
+ RATE_BUCKET_CAPACITY = 60
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Logging
115
+ # ---------------------------------------------------------------------------
116
+
117
+ logger = logging.getLogger("topogpt3.api")
118
+ _audit_logger = logging.getLogger("topogpt3.api.audit")
119
+
120
+
121
+ def _setup_logging(verbose: bool = False) -> None:
122
+ fmt = logging.Formatter(
123
+ '{"time":"%(asctime)s","level":"%(levelname)s",'
124
+ '"logger":"%(name)s","msg":%(message)s}',
125
+ datefmt="%Y-%m-%dT%H:%M:%S",
126
+ )
127
+ h = logging.StreamHandler(sys.stderr)
128
+ h.setFormatter(fmt)
129
+ level = logging.DEBUG if verbose else logging.INFO
130
+ for lg in (logger, _audit_logger):
131
+ lg.setLevel(level)
132
+ lg.handlers = [h]
133
+ lg.propagate = False
134
+
135
+
136
+ # ═══════════════════════════════════════════════════════════════════════════
137
+ # Authentication
138
+ # ═══════════════════════════════════════════════════════════════════════════
139
+
140
+
141
+ @dataclass
142
+ class ApiKey:
143
+ key_hash: str # SHA-256 hex
144
+ role: str # "admin" | "user"
145
+
146
+
147
+ @dataclass
148
+ class AuthState:
149
+ keys: Set[str] = field(default_factory=set) # raw keys for lookup
150
+ by_hash: Dict[str, ApiKey] = field(default_factory=dict)
151
+ enabled: bool = True
152
+
153
+ def validate(self, raw: str | None) -> ApiKey:
154
+ if not self.enabled:
155
+ return ApiKey(key_hash="", role="admin")
156
+ if not raw:
157
+ raise HTTPException(
158
+ status_code=401,
159
+ detail="Missing Authorization header. Use: Bearer <key>",
160
+ )
161
+ # Constant-time check: hash the candidate, then compare digests
162
+ candidate_hash = _sha256(raw)
163
+ stored = self.by_hash.get(candidate_hash)
164
+ if stored is None:
165
+ raise HTTPException(status_code=401, detail="Invalid API key")
166
+ return stored
167
+
168
+
169
+ def _parse_keys(raw: str) -> AuthState:
170
+ """Accept ``key1,admin:key2,key3``. The ``admin:`` prefix marks an
171
+ admin-level key; everything else is a regular user key."""
172
+ state = AuthState()
173
+ for entry in raw.split(","):
174
+ entry = entry.strip()
175
+ if not entry:
176
+ continue
177
+ if entry.startswith("admin:"):
178
+ raw_key = entry[len("admin:"):].strip()
179
+ role = "admin"
180
+ else:
181
+ raw_key = entry.strip()
182
+ role = "user"
183
+ if not raw_key or len(raw_key) < 6:
184
+ logger.warning("skipping short/invalid key: %s...", entry[:16])
185
+ continue
186
+ state.keys.add(raw_key)
187
+ state.by_hash[_sha256(raw_key)] = ApiKey(key_hash=_sha256(raw_key), role=role)
188
+ if not state.keys:
189
+ logger.warning("No valid API keys configured — auth is DISABLED")
190
+ state.enabled = False
191
+ else:
192
+ logger.info("Loaded %d API key(s) (%d admin)", len(state.keys),
193
+ sum(1 for k in state.by_hash.values() if k.role == "admin"))
194
+ return state
195
+
196
+
197
+ def _sha256(raw: str) -> str:
198
+ return hashlib.sha256(raw.encode()).hexdigest()
199
+
200
+
201
+ # ═══════════════════════════════════════════════════════════════════════════
202
+ # Rate limiter + IP ban
203
+ # ═══════════════════════════════════════════════════════════════════════════
204
+
205
+
206
+ @dataclass
207
+ class TokenBucket:
208
+ tokens: float
209
+ last_refill: float
210
+ rate: float
211
+ capacity: float
212
+
213
+ def consume(self, n: float = 1.0) -> bool:
214
+ now = time.monotonic()
215
+ elapsed = now - self.last_refill
216
+ self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
217
+ self.last_refill = now
218
+ if self.tokens >= n:
219
+ self.tokens -= n
220
+ return True
221
+ return False
222
+
223
+
224
+ class RateLimiter:
225
+ def __init__(self, user_rps: float, admin_rps: float, capacity: float):
226
+ self._user_rps = user_rps
227
+ self._admin_rps = admin_rps
228
+ self._capacity = capacity
229
+ self._buckets: dict[str, TokenBucket] = {}
230
+ self._lock = __import__("threading").Lock()
231
+
232
+ def _cleanup(self) -> None:
233
+ cutoff = time.monotonic() - 600
234
+ stale = [k for k, b in self._buckets.items() if b.last_refill < cutoff]
235
+ for k in stale:
236
+ del self._buckets[k]
237
+
238
+ def allow(self, key: str, role: str = "user") -> bool:
239
+ rate = self._admin_rps if role == "admin" else self._user_rps
240
+ with self._lock:
241
+ if len(self._buckets) > 100_000:
242
+ self._cleanup()
243
+ bucket = self._buckets.get(key)
244
+ if bucket is None or bucket.rate != rate:
245
+ self._buckets[key] = TokenBucket(
246
+ tokens=self._capacity,
247
+ last_refill=time.monotonic(),
248
+ rate=rate,
249
+ capacity=self._capacity,
250
+ )
251
+ bucket = self._buckets[key]
252
+ return bucket.consume()
253
+
254
+
255
+ class IpBanner:
256
+ def __init__(self, max_failures: int = MAX_AUTH_FAILURES, window: float = BAN_WINDOW_S):
257
+ self._max = max_failures
258
+ self._window = window
259
+ self._failures: dict[str, list[float]] = defaultdict(list)
260
+ self._banned: dict[str, float] = {}
261
+
262
+ def record_failure(self, ip: str) -> None:
263
+ now = time.monotonic()
264
+ self._failures[ip] = [t for t in self._failures[ip] if now - t < self._window]
265
+ self._failures[ip].append(now)
266
+ if len(self._failures[ip]) >= self._max:
267
+ self._banned[ip] = now
268
+ _audit_logger.warning('{"event":"ip_banned","ip":"%s","failures":%d}', ip, len(self._failures[ip]))
269
+
270
+ def is_banned(self, ip: str) -> bool:
271
+ banned_at = self._banned.get(ip)
272
+ if banned_at and time.monotonic() - banned_at < self._window:
273
+ return True
274
+ if banned_at:
275
+ del self._banned[ip]
276
+ return False
277
+
278
+
279
+ # ═══════════════════════════════════════════════════════════════════════════
280
+ # Pydantic request schemas (strict validation)
281
+ # ═══════════════════════════════════════════════════════════════════════════
282
+
283
+ _SAFE_RE = re.compile(r"^[ -~]*$") # printable ASCII, no control chars
284
+
285
+
286
+ def _sanitize_stop(stop: Any) -> list[str]:
287
+ if stop is None:
288
+ return []
289
+ if isinstance(stop, str):
290
+ return [stop[:MAX_STOP_STRING_LEN]]
291
+ if isinstance(stop, list):
292
+ return [str(s)[:MAX_STOP_STRING_LEN] for s in stop[:MAX_STOP_STRINGS]]
293
+ return []
294
+
295
+
296
+ class CompletionRequest(BaseModel):
297
+ model_config = {"extra": "allow"}
298
+ model: str = "topogpt3"
299
+ prompt: str = Field(default="", max_length=MAX_PROMPT_CHARS)
300
+ max_tokens: int = Field(default=512, ge=1, le=MAX_OUTPUT_TOKENS)
301
+ temperature: float = Field(default=0.7, ge=0.0, le=MAX_TEMPERATURE)
302
+ top_k: int = Field(default=50, ge=0, le=200)
303
+ repetition_penalty: float = Field(default=1.1, ge=0.5, le=3.0)
304
+ stop: Any = None
305
+ stream: bool = False
306
+ auto_continue: bool = False
307
+ max_continuations: int = Field(default=3, ge=0, le=10)
308
+ seed: int | None = Field(default=None, ge=0, le=2**63 - 1)
309
+ response_format: Any = None
310
+ so_args: Any = None
311
+
312
+ @field_validator("stop", mode="before")
313
+ @classmethod
314
+ def _normalize_stop(cls, v: Any) -> list[str]:
315
+ return _sanitize_stop(v)
316
+
317
+
318
+ class Message(BaseModel):
319
+ model_config = {"extra": "allow"}
320
+ role: str = Field(default="user", pattern=r"^(system|user|assistant)$")
321
+ content: str | list[dict] = Field(default="")
322
+
323
+
324
+ class ChatCompletionRequest(BaseModel):
325
+ model_config = {"extra": "allow"}
326
+ model: str = "topogpt3"
327
+ messages: list[Message] = Field(default_factory=list, max_length=MAX_MESSAGES)
328
+ max_tokens: int = Field(default=512, ge=1, le=MAX_OUTPUT_TOKENS)
329
+ temperature: float = Field(default=0.7, ge=0.0, le=MAX_TEMPERATURE)
330
+ top_k: int = Field(default=50, ge=0, le=200)
331
+ repetition_penalty: float = Field(default=1.1, ge=0.5, le=3.0)
332
+ stop: Any = None
333
+ stream: bool = False
334
+ auto_continue: bool = False
335
+ max_continuations: int = Field(default=3, ge=0, le=10)
336
+ seed: int | None = Field(default=None, ge=0, le=2**63 - 1)
337
+ response_format: Any = None
338
+ so_args: Any = None
339
+
340
+ @field_validator("stop", mode="before")
341
+ @classmethod
342
+ def _normalize_stop(cls, v: Any) -> list[str]:
343
+ return _sanitize_stop(v)
344
+
345
+
346
+ # ═══════════════════════════════════════════════════════════════════════════
347
+ # Model wrapper
348
+ # ═══════════════════════════════════════════════════════════════════════════
349
+
350
+
351
+ def _make_generator(seed: int | None, device: str) -> torch.Generator | None:
352
+ """Per-request RNG so a fixed ``seed`` reproduces the exact same sample."""
353
+ if seed is None:
354
+ return None
355
+ if device.startswith("cuda"):
356
+ generator = torch.Generator(device=torch.device(device))
357
+ elif device == "cpu":
358
+ generator = torch.Generator(device="cpu")
359
+ else:
360
+ return None
361
+ generator.manual_seed(int(seed))
362
+ return generator
363
+
364
+
365
+ @dataclass
366
+ class ServerModel:
367
+ model: TopoGPT2
368
+ tokenizer: BPETokenizer
369
+ device: str
370
+ checkpoint: str
371
+
372
+ @contextmanager
373
+ def _seeded(self, seed: int | None):
374
+ if seed is None:
375
+ yield
376
+ return
377
+ cuda_devices: list[int] = []
378
+ if self.device.startswith("cuda"):
379
+ index = self.device.partition(":")[2]
380
+ cuda_devices = [int(index) if index else torch.cuda.current_device()]
381
+ with torch.random.fork_rng(devices=cuda_devices):
382
+ torch.manual_seed(int(seed))
383
+ if cuda_devices:
384
+ torch.cuda.manual_seed_all(int(seed))
385
+ yield
386
+
387
+ @torch.no_grad()
388
+ def _generate(
389
+ self,
390
+ token_ids: torch.Tensor,
391
+ *,
392
+ max_new_tokens: int,
393
+ temperature: float,
394
+ top_k: int,
395
+ repetition_penalty: float,
396
+ seed: int | None,
397
+ constraint: GrammarConstraint | None,
398
+ ) -> torch.Tensor:
399
+ generator = _make_generator(seed, self.device)
400
+ logits, _, past_kvs = self.model(token_ids)
401
+ for _ in range(max_new_tokens):
402
+ step = logits[:, -1, :]
403
+ if repetition_penalty != 1.0:
404
+ mask = torch.zeros_like(step, dtype=torch.bool)
405
+ mask.scatter_(1, token_ids, True)
406
+ step = torch.where(mask, step / repetition_penalty, step)
407
+ if temperature <= 1e-6:
408
+ if constraint is not None:
409
+ constraint.apply(step)
410
+ next_tok = step.argmax(dim=-1, keepdim=True)
411
+ else:
412
+ step = step / temperature
413
+ if top_k > 0:
414
+ v, _ = torch.topk(step, min(top_k, step.size(-1)))
415
+ step[step < v[:, -1:]] = float("-inf")
416
+ if constraint is not None:
417
+ constraint.apply(step)
418
+ probs = torch.softmax(step, dim=-1)
419
+ if not torch.isfinite(probs).all() or probs.sum().item() <= 0:
420
+ break
421
+ next_tok = torch.multinomial(probs, 1, generator=generator)
422
+
423
+ token_id = int(next_tok.item())
424
+ if constraint is not None:
425
+ if not constraint.accept(token_id):
426
+ break
427
+ finished = constraint.is_terminated
428
+ else:
429
+ if self._is_eos(token_id):
430
+ break
431
+ finished = False
432
+
433
+ token_ids = torch.cat([token_ids, next_tok], dim=1)
434
+ if finished:
435
+ break
436
+ logits, _, past_kvs = self.model(next_tok, past_kvs=past_kvs)
437
+ return token_ids
438
+
439
+ @torch.no_grad()
440
+ def complete(
441
+ self,
442
+ prompt: str,
443
+ *,
444
+ max_new_tokens: int = 512,
445
+ temperature: float = 0.7,
446
+ top_k: int = 50,
447
+ repetition_penalty: float = 1.1,
448
+ stop: list[str] | None = None,
449
+ auto_continue: bool = False,
450
+ max_continuations: int = 3,
451
+ seed: int | None = None,
452
+ constraint: GrammarConstraint | None = None,
453
+ ) -> str:
454
+ ids = self.tokenizer.encode(prompt)
455
+ if not ids:
456
+ return ""
457
+ max_ctx = self.model.config.MAX_SEQ_LEN
458
+ ids = ids[-max_ctx:]
459
+ x = torch.tensor([ids], dtype=torch.long, device=self.device)
460
+ if auto_continue:
461
+ if constraint is not None:
462
+ raise StructuredOutputError(
463
+ "auto_continue cannot be combined with structured output"
464
+ )
465
+ with self._seeded(seed):
466
+ out = self.model.generate_with_continuation(
467
+ x,
468
+ tokenizer=self.tokenizer,
469
+ max_new_tokens=max_new_tokens,
470
+ temperature=temperature,
471
+ top_k=top_k,
472
+ repetition_penalty=repetition_penalty,
473
+ max_continuations=max_continuations,
474
+ )
475
+ else:
476
+ out = self._generate(
477
+ x,
478
+ max_new_tokens=max_new_tokens,
479
+ temperature=temperature,
480
+ top_k=top_k,
481
+ repetition_penalty=repetition_penalty,
482
+ seed=seed,
483
+ constraint=constraint,
484
+ )
485
+ text = self.tokenizer.decode(out[0].tolist())
486
+ if stop:
487
+ for s in stop:
488
+ idx = text.find(s)
489
+ if idx != -1:
490
+ text = text[:idx]
491
+ return text
492
+
493
+ @torch.no_grad()
494
+ def stream_complete(
495
+ self,
496
+ prompt: str,
497
+ *,
498
+ max_new_tokens: int = 512,
499
+ temperature: float = 0.7,
500
+ top_k: int = 50,
501
+ repetition_penalty: float = 1.1,
502
+ stop: list[str] | None = None,
503
+ auto_continue: bool = False,
504
+ max_continuations: int = 3,
505
+ seed: int | None = None,
506
+ constraint: GrammarConstraint | None = None,
507
+ ):
508
+ ids = self.tokenizer.encode(prompt)
509
+ if not ids:
510
+ return
511
+ if auto_continue and constraint is not None:
512
+ raise StructuredOutputError(
513
+ "auto_continue cannot be combined with structured output"
514
+ )
515
+ max_ctx = self.model.config.MAX_SEQ_LEN
516
+ ids = ids[-max_ctx:]
517
+ x = torch.tensor([ids], dtype=torch.long, device=self.device)
518
+ max_tokens = min(max_new_tokens, MAX_OUTPUT_TOKENS)
519
+ generator = _make_generator(seed, self.device)
520
+ prev_text = self.tokenizer.decode(x[0].tolist())
521
+ prev_chars = len(prev_text)
522
+ out = x
523
+ continuation_count = 0
524
+
525
+ while True:
526
+ stopped_on_eos = False
527
+ grammar_finished = False
528
+ if constraint is not None:
529
+ constraint.reset()
530
+ for _ in range(max_tokens):
531
+ with torch.no_grad():
532
+ logits, _, _ = self.model(out)
533
+ step = logits[:, -1, :]
534
+ if repetition_penalty != 1.0:
535
+ mask = torch.zeros_like(step, dtype=torch.bool)
536
+ mask.scatter_(1, out, True)
537
+ step = torch.where(mask, step / repetition_penalty, step)
538
+ if temperature <= 1e-6:
539
+ if constraint is not None:
540
+ constraint.apply(step)
541
+ next_id = step.argmax(dim=-1, keepdim=True)
542
+ else:
543
+ step = step / temperature
544
+ if top_k > 0:
545
+ v, _ = torch.topk(step, min(top_k, step.size(-1)))
546
+ step[step < v[:, -1:]] = float("-inf")
547
+ if constraint is not None:
548
+ constraint.apply(step)
549
+ probs = torch.softmax(step, dim=-1)
550
+ if not torch.isfinite(probs).all() or probs.sum().item() <= 0:
551
+ break
552
+ next_id = torch.multinomial(probs, 1, generator=generator)
553
+
554
+ token_id = int(next_id.item())
555
+ if constraint is not None:
556
+ if not constraint.accept(token_id):
557
+ grammar_finished = True
558
+ break
559
+ if constraint.is_terminated:
560
+ grammar_finished = True
561
+ elif self._is_eos(token_id):
562
+ stopped_on_eos = True
563
+ break
564
+
565
+ out = torch.cat([out, next_id], dim=1)
566
+ full_text = self.tokenizer.decode(out[0].tolist())
567
+ chunk = full_text[prev_chars:]
568
+ prev_chars = len(full_text)
569
+ if not chunk:
570
+ if grammar_finished:
571
+ return
572
+ continue
573
+ if stop:
574
+ stopped = False
575
+ for s in stop:
576
+ idx_s = chunk.find(s)
577
+ if idx_s != -1:
578
+ chunk = chunk[:idx_s]
579
+ stopped = True
580
+ break
581
+ if stopped:
582
+ if chunk:
583
+ yield chunk
584
+ return
585
+ yield chunk
586
+ if grammar_finished:
587
+ return
588
+
589
+ if grammar_finished or not auto_continue or not stopped_on_eos:
590
+ break
591
+
592
+ from .continuation import is_response_complete, extract_tail_for_continuation
593
+
594
+ out_text = self.tokenizer.decode(out[0].tolist())
595
+ prompt_text = prev_text
596
+ generated = out_text[len(prompt_text):]
597
+
598
+ if is_response_complete(generated):
599
+ break
600
+
601
+ tail = extract_tail_for_continuation(generated, tail_lines=2)
602
+ if not tail:
603
+ break
604
+
605
+ continuation_count += 1
606
+ if continuation_count > max_continuations:
607
+ break
608
+
609
+ tail_text = prompt_text + tail
610
+ tail_ids = self.tokenizer.encode(tail_text)
611
+ tail_ids = tail_ids[-max_ctx:]
612
+ out = torch.tensor([tail_ids], dtype=torch.long, device=self.device)
613
+ prev_text = tail_text
614
+ prev_chars = len(tail_text)
615
+
616
+ def _is_eos(self, token_id: int) -> bool:
617
+ try:
618
+ return int(token_id) == int(self.tokenizer.eot_token())
619
+ except Exception:
620
+ return False
621
+
622
+
623
+ # ═══════════════════════════════════════════════════════════════════════════
624
+ # App state
625
+ # ═══════════════════════════════════════════════════════════════════════════
626
+
627
+ _MODEL: ServerModel | None = None
628
+ _AUTH: AuthState = AuthState(enabled=False)
629
+ _RATE_LIMITER: RateLimiter = RateLimiter(RATE_LIMIT_USER_RPS, RATE_LIMIT_ADMIN_RPS, RATE_BUCKET_CAPACITY)
630
+ _IP_BANNER: IpBanner = IpBanner()
631
+
632
+
633
+ def _resolve_device(device: str | None) -> str:
634
+ if device:
635
+ return device
636
+ return "cuda" if torch.cuda.is_available() else "cpu"
637
+
638
+
639
+ def _probe_arch(checkpoint_dir: str) -> tuple[str, int]:
640
+ """Probe a checkpoint and return (scale, n_kv_heads).
641
+
642
+ Reads the model hidden dim from ``final_norm.weight`` (or the first
643
+ transformer layer's output projection) and the KV head count from the
644
+ ``k_proj`` output dim, so the server can load checkpoints of any scale
645
+ (small/large/…) without hardcoding the preset.
646
+ """
647
+ from safetensors import safe_open
648
+ path = Path(checkpoint_dir) / "model.safetensors"
649
+ with safe_open(str(path), framework="pt", device="cpu") as h:
650
+ # Discover d_model from the final norm / output projection.
651
+ d_model = None
652
+ for key in ("final_norm.weight", "lm_head.weight", "token_embed.weight"):
653
+ if key in h.keys():
654
+ t = h.get_tensor(key)
655
+ d_model = t.shape[0] if t.ndim == 1 else t.shape[1]
656
+ break
657
+ if d_model is None:
658
+ # Fall back to the first layer's o_proj output dim.
659
+ for key in h.keys():
660
+ if key.endswith("attn.o_proj.weight"):
661
+ t = h.get_tensor(key)
662
+ d_model = t.shape[0]
663
+ break
664
+ if d_model is None:
665
+ raise ValueError("Could not determine d_model from checkpoint")
666
+
667
+ # Map d_model -> scale preset. N_HEADS is inferred from the matching
668
+ # preset (large => 8 heads, d_head = 512/8 = 64).
669
+ scale_by_dim = {64: "micro", 256: "small", 512: "large", 768: "gpt2"}
670
+ scale = scale_by_dim.get(d_model, "small")
671
+ n_heads = 8 if scale in ("small", "large", "medium") else (
672
+ 4 if scale == "micro" else 12
673
+ )
674
+ d_head = d_model // n_heads
675
+
676
+ # n_kv_heads from k_proj out dim (supports grouped-query attention).
677
+ k_dim = None
678
+ for key in h.keys():
679
+ if key.endswith("attn.k_proj.weight"):
680
+ k_dim = h.get_tensor(key).shape[0]
681
+ break
682
+ n_kv = (k_dim // d_head) if k_dim else n_heads
683
+
684
+ return scale, n_kv
685
+
686
+
687
+ def load_model(checkpoint: str, device: str | None = None) -> ServerModel:
688
+ device = _resolve_device(device)
689
+ scale, n_kv = _probe_arch(checkpoint)
690
+ cfg = TopoGPT2Config(SCALE=scale, DEVICE=device, N_KV_HEADS=n_kv)
691
+ tokenizer = BPETokenizer("gpt2")
692
+ cfg.VOCAB_SIZE = tokenizer.vocab_size
693
+ model = TopoGPT2(cfg).to(device).eval()
694
+ sd = load_file(str(Path(checkpoint) / "model.safetensors"), device=device)
695
+ model.load_state_dict(sd, strict=False)
696
+ set_seed(42, device)
697
+ n_params = sum(p.numel() for p in model.parameters())
698
+ logger.info(
699
+ '{"event":"model_loaded","params":%d,"n_kv":%d,"scale":"%s","device":"%s"}',
700
+ n_params, n_kv, scale, device,
701
+ )
702
+ return ServerModel(model=model, tokenizer=tokenizer, device=device, checkpoint=checkpoint)
703
+
704
+
705
+ # ═══════════════════════════════════════════════════════════════════════════
706
+ # FastAPI app
707
+ # ═══════════════════════════════════════════════════════════════════════════
708
+
709
+
710
+ @asynccontextmanager
711
+ async def lifespan(app: FastAPI):
712
+ global _MODEL, _AUTH
713
+ checkpoint = os.environ.get("TOPOGPT3_CHECKPOINT", "checkpoints_topogpt3/last")
714
+ device = os.environ.get("TOPOGPT3_DEVICE") or None
715
+ api_keys = os.environ.get("TOPOGPT3_API_KEYS", "")
716
+ app.state.checkpoint = checkpoint
717
+ app.state.device = device
718
+ app.state.api_keys = api_keys
719
+ _MODEL = load_model(checkpoint, device)
720
+ _AUTH = _parse_keys(api_keys)
721
+ yield
722
+ _MODEL = None
723
+
724
+
725
+ app = FastAPI(
726
+ title="TopoGPT3 API",
727
+ version="0.1.0",
728
+ lifespan=lifespan,
729
+ docs_url=None if os.environ.get("TOPOGPT3_NO_DOCS") else "/docs",
730
+ redoc_url=None,
731
+ openapi_url=None if os.environ.get("TOPOGPT3_NO_DOCS") else "/openapi.json",
732
+ )
733
+
734
+ # ── Middleware ────────────────────────────────────────────────────────────
735
+
736
+ # CORS: default deny-all; allowlist via TOPOGPT3_CORS_ORIGINS
737
+ _cors_origins = os.environ.get("TOPOGPT3_CORS_ORIGINS", "").split(",")
738
+ _cors_origins = [o.strip() for o in _cors_origins if o.strip()]
739
+ app.add_middleware(
740
+ CORSMiddleware,
741
+ allow_origins=_cors_origins or [],
742
+ allow_credentials=False,
743
+ allow_methods=["GET", "POST", "OPTIONS"],
744
+ allow_headers=["Authorization", "Content-Type"],
745
+ )
746
+
747
+ app.add_middleware(GZipMiddleware, minimum_size=512)
748
+
749
+
750
+ @app.middleware("http")
751
+ async def _security_middleware(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
752
+ """Global middleware: rate-limit, IP-ban, security headers, audit log."""
753
+
754
+ client_ip = _real_ip(request)
755
+ if _IP_BANNER.is_banned(client_ip):
756
+ _audit_logger.warning('{"event":"blocked","reason":"ip_banned","ip":"%s","path":"%s"}',
757
+ client_ip, request.url.path)
758
+ return _json_error(403, "Access denied")
759
+
760
+ # Rate limit by IP (before auth so brute-force is throttled)
761
+ if not _RATE_LIMITER.allow(f"ip:{client_ip}", role="user"):
762
+ _audit_logger.warning('{"event":"rate_limited","ip":"%s","path":"%s"}',
763
+ client_ip, request.url.path)
764
+ return _json_error(429, "Too many requests. Slow down.")
765
+
766
+ response = await call_next(request)
767
+
768
+ # Security headers on every response
769
+ response.headers["X-Content-Type-Options"] = "nosniff"
770
+ response.headers["X-Frame-Options"] = "DENY"
771
+ response.headers["X-XSS-Protection"] = "1; mode=block"
772
+ response.headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'"
773
+ response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
774
+ response.headers["Server"] = "" # don't leak framework version
775
+
776
+ return response
777
+
778
+
779
+ def _real_ip(request: Request) -> str:
780
+ """Best-effort real client IP. We trust no proxy headers by default."""
781
+ # Only trust X-Forwarded-For if explicitly allowed
782
+ if os.environ.get("TOPOGPT3_TRUST_PROXY", "").lower() in ("1", "true", "yes"):
783
+ forwarded = request.headers.get("X-Forwarded-For", "")
784
+ if forwarded:
785
+ return forwarded.split(",")[0].strip()
786
+ host = getattr(request, "client", None)
787
+ return host.host if host else "127.0.0.1"
788
+
789
+
790
+ def _json_error(status: int, detail: str) -> JSONResponse:
791
+ return JSONResponse(
792
+ content={"error": detail},
793
+ status_code=status,
794
+ )
795
+
796
+
797
+ # ═══════════════════════════════════════════════════════════════════════════
798
+ # Auth dependency
799
+ # ═══════════════════════════════════════════════════════════════════════════
800
+
801
+
802
+ async def _authenticate(request: Request) -> ApiKey:
803
+ """FastAPI dependency: extract & validate Bearer token.
804
+
805
+ When no API keys are configured (auth disabled via ``--no-auth`` or an
806
+ empty ``TOPOGPT3_API_KEYS``), the dependency is a pass-through so local
807
+ integrations (e.g. the ExploitGym TopoExploit agent) can call it without
808
+ a token.
809
+ """
810
+ client_ip = _real_ip(request)
811
+ if _AUTH is None or not _AUTH.enabled:
812
+ return ApiKey(key_hash="", role="admin")
813
+ auth_header = request.headers.get("Authorization", "")
814
+ if not auth_header.lower().startswith("bearer "):
815
+ _IP_BANNER.record_failure(client_ip)
816
+ raise HTTPException(
817
+ status_code=401,
818
+ detail="Missing or malformed Authorization header. Use: Bearer <key>",
819
+ )
820
+ raw_key = auth_header[7:] # len("Bearer ") == 7
821
+ try:
822
+ return _AUTH.validate(raw_key)
823
+ except HTTPException:
824
+ _IP_BANNER.record_failure(client_ip)
825
+ raise
826
+
827
+
828
+ async def _check_rate_limit(api_key: ApiKey, request: Request) -> None:
829
+ """Rate limit per-key (with admin exemption / higher limit)."""
830
+ client_ip = _real_ip(request)
831
+ bucket_key = f"key:{api_key.key_hash}"
832
+ if not _RATE_LIMITER.allow(bucket_key, role=api_key.role):
833
+ _audit_logger.warning(
834
+ '{"event":"rate_limited","ip":"%s","role":"%s"}',
835
+ client_ip, api_key.role,
836
+ )
837
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
838
+
839
+
840
+ # ═══════════════════════════════════════════════════════════════════════════
841
+ # Endpoints
842
+ # ═══════════════════════════════════════════════════════════════════════════
843
+
844
+
845
+ @app.get("/health")
846
+ async def health(request: Request):
847
+ if _MODEL is None:
848
+ return _json_error(503, "Model not loaded yet")
849
+ return {"status": "ok"}
850
+
851
+
852
+ @app.get("/v1/models")
853
+ async def list_models(request: Request):
854
+ _api_key = await _authenticate(request)
855
+ await _check_rate_limit(_api_key, request)
856
+ return {
857
+ "object": "list",
858
+ "data": [
859
+ {
860
+ "id": "topogpt3",
861
+ "object": "model",
862
+ "created": 0,
863
+ "owned_by": "topogpt3",
864
+ }
865
+ ],
866
+ }
867
+
868
+
869
+ @app.post("/v1/completions")
870
+ async def completions(req: CompletionRequest, request: Request):
871
+ _api_key = await _authenticate(request)
872
+ await _check_rate_limit(_api_key, request)
873
+ _check_model()
874
+ constraint = _request_constraint(req)
875
+
876
+ _audit_logger.info(
877
+ '{"event":"completion","role":"%s","stream":%s,"prompt_len":%d,"max_tokens":%d}',
878
+ _api_key.role, req.stream, len(req.prompt), req.max_tokens,
879
+ )
880
+
881
+ if req.stream:
882
+ return StreamingResponse(
883
+ _stream_completion(
884
+ req.prompt, req.max_tokens, req.temperature,
885
+ req.top_k, req.repetition_penalty, req.stop,
886
+ req.auto_continue, req.max_continuations,
887
+ seed=req.seed, constraint=constraint,
888
+ ),
889
+ media_type="text/event-stream",
890
+ )
891
+
892
+ t0 = time.monotonic()
893
+ text = _MODEL.complete(
894
+ req.prompt,
895
+ max_new_tokens=req.max_tokens,
896
+ temperature=req.temperature,
897
+ top_k=req.top_k,
898
+ repetition_penalty=req.repetition_penalty,
899
+ stop=req.stop,
900
+ auto_continue=req.auto_continue,
901
+ max_continuations=req.max_continuations,
902
+ seed=req.seed,
903
+ constraint=constraint,
904
+ )
905
+ elapsed = time.monotonic() - t0
906
+ prompt_tokens = len(_MODEL.tokenizer.encode(req.prompt[:MAX_PROMPT_CHARS]))
907
+ completion_tokens = max(len(_MODEL.tokenizer.encode(text[len(req.prompt):])), 1)
908
+ return {
909
+ "id": f"cmpl-{_short_id()}",
910
+ "object": "text_completion",
911
+ "created": int(time.time()),
912
+ "model": "topogpt3",
913
+ "choices": [
914
+ {
915
+ "text": text[len(req.prompt):],
916
+ "index": 0,
917
+ "logprobs": None,
918
+ "finish_reason": "stop",
919
+ }
920
+ ],
921
+ "usage": {
922
+ "prompt_tokens": prompt_tokens,
923
+ "completion_tokens": completion_tokens,
924
+ "total_tokens": prompt_tokens + completion_tokens,
925
+ },
926
+ }
927
+
928
+
929
+ @app.post("/v1/chat/completions")
930
+ async def chat_completions(req: ChatCompletionRequest, request: Request):
931
+ _api_key = await _authenticate(request)
932
+ await _check_rate_limit(_api_key, request)
933
+ _check_model()
934
+ constraint = _request_constraint(req)
935
+
936
+ prompt = _build_chat_prompt(req.messages)
937
+ _audit_logger.info(
938
+ '{"event":"chat","role":"%s","stream":%s,"n_msgs":%d,"prompt_len":%d}',
939
+ _api_key.role, req.stream, len(req.messages), len(prompt),
940
+ )
941
+
942
+ if req.stream:
943
+ return StreamingResponse(
944
+ _stream_chat(
945
+ int(time.time() * 1000), prompt, req.max_tokens,
946
+ req.temperature, req.top_k, req.repetition_penalty, req.stop,
947
+ req.auto_continue, req.max_continuations,
948
+ seed=req.seed, constraint=constraint,
949
+ ),
950
+ media_type="text/event-stream",
951
+ )
952
+
953
+ t0 = time.monotonic()
954
+ text = _MODEL.complete(
955
+ prompt,
956
+ max_new_tokens=req.max_tokens,
957
+ temperature=req.temperature,
958
+ top_k=req.top_k,
959
+ repetition_penalty=req.repetition_penalty,
960
+ stop=req.stop,
961
+ auto_continue=req.auto_continue,
962
+ max_continuations=req.max_continuations,
963
+ seed=req.seed,
964
+ constraint=constraint,
965
+ )
966
+ response_text = text[len(prompt):]
967
+ return {
968
+ "id": f"chatcmpl-{_short_id()}",
969
+ "object": "chat.completion",
970
+ "created": int(time.time()),
971
+ "model": "topogpt3",
972
+ "choices": [
973
+ {
974
+ "index": 0,
975
+ "message": {"role": "assistant", "content": response_text},
976
+ "finish_reason": "stop",
977
+ }
978
+ ],
979
+ "usage": {
980
+ "prompt_tokens": len(_MODEL.tokenizer.encode(prompt)),
981
+ "completion_tokens": len(_MODEL.tokenizer.encode(response_text)),
982
+ "total_tokens": len(_MODEL.tokenizer.encode(text)),
983
+ },
984
+ }
985
+
986
+
987
+ # ═══════════════════════════════════════════════════════════════════════════
988
+ # Helpers
989
+ # ═══════════════════════════════════════════════════════════════════════════
990
+
991
+
992
+ def _check_model():
993
+ if _MODEL is None:
994
+ raise HTTPException(status_code=503, detail="Model not loaded yet")
995
+
996
+
997
+ def _request_constraint(req: Any) -> GrammarConstraint | None:
998
+ if req.auto_continue and (
999
+ req.response_format is not None or req.so_args is not None
1000
+ ):
1001
+ raise HTTPException(
1002
+ status_code=400,
1003
+ detail="auto_continue cannot be combined with structured output",
1004
+ )
1005
+ try:
1006
+ return constraint_from_request(
1007
+ _MODEL.tokenizer, req.response_format, req.so_args
1008
+ )
1009
+ except StructuredOutputError as exc:
1010
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
1011
+
1012
+
1013
+ def _short_id() -> str:
1014
+ return secrets.token_hex(8)
1015
+
1016
+
1017
+ def _build_chat_prompt(messages: list[Message]) -> str:
1018
+ for msg in reversed(messages):
1019
+ if msg.role == "user":
1020
+ return _extract_text(msg.content)
1021
+ return ""
1022
+
1023
+
1024
+ def _extract_text(content: str | list[dict] | Any) -> str:
1025
+ if isinstance(content, str):
1026
+ return content[-MAX_CONTENT_LEN:]
1027
+ if isinstance(content, list):
1028
+ texts: list[str] = []
1029
+ for block in content:
1030
+ if isinstance(block, dict):
1031
+ t = block.get("text", "")
1032
+ if t:
1033
+ texts.append(str(t))
1034
+ return " ".join(texts)[-MAX_CONTENT_LEN:]
1035
+ return str(content)[-MAX_CONTENT_LEN:]
1036
+
1037
+
1038
+ async def _stream_completion(
1039
+ prompt: str, max_tokens: int, temperature: float,
1040
+ top_k: int, repetition_penalty: float, stop: list[str],
1041
+ auto_continue: bool = False, max_continuations: int = 3,
1042
+ seed: int | None = None, constraint: GrammarConstraint | None = None,
1043
+ ):
1044
+ t0 = int(time.time())
1045
+ short_id = _short_id()
1046
+ for chunk_text in _MODEL.stream_complete(
1047
+ prompt, max_new_tokens=max_tokens, temperature=temperature,
1048
+ top_k=top_k, repetition_penalty=repetition_penalty, stop=stop,
1049
+ auto_continue=auto_continue, max_continuations=max_continuations,
1050
+ seed=seed, constraint=constraint,
1051
+ ):
1052
+ data = {
1053
+ "id": f"cmpl-{short_id}",
1054
+ "object": "text_completion",
1055
+ "created": t0,
1056
+ "model": "topogpt3",
1057
+ "choices": [
1058
+ {"text": chunk_text, "index": 0, "logprobs": None, "finish_reason": None}
1059
+ ],
1060
+ }
1061
+ yield f"data: {json.dumps(data)}\n\n"
1062
+ data = {
1063
+ "id": f"cmpl-{short_id}",
1064
+ "object": "text_completion",
1065
+ "created": t0,
1066
+ "model": "topogpt3",
1067
+ "choices": [
1068
+ {"text": "", "index": 0, "logprobs": None, "finish_reason": "stop"}
1069
+ ],
1070
+ }
1071
+ yield f"data: {json.dumps(data)}\n\n"
1072
+ yield "data: [DONE]\n\n"
1073
+
1074
+
1075
+ async def _stream_chat(
1076
+ t0_ms: int, prompt: str, max_tokens: int, temperature: float,
1077
+ top_k: int, repetition_penalty: float, stop: list[str],
1078
+ auto_continue: bool = False, max_continuations: int = 3,
1079
+ seed: int | None = None, constraint: GrammarConstraint | None = None,
1080
+ ):
1081
+ for chunk_text in _MODEL.stream_complete(
1082
+ prompt, max_new_tokens=max_tokens, temperature=temperature,
1083
+ top_k=top_k, repetition_penalty=repetition_penalty, stop=stop,
1084
+ auto_continue=auto_continue, max_continuations=max_continuations,
1085
+ seed=seed, constraint=constraint,
1086
+ ):
1087
+ data = {
1088
+ "id": f"chatcmpl-{_short_id()}",
1089
+ "object": "chat.completion.chunk",
1090
+ "created": t0_ms // 1000,
1091
+ "model": "topogpt3",
1092
+ "choices": [
1093
+ {"index": 0, "delta": {"content": chunk_text}, "finish_reason": None}
1094
+ ],
1095
+ }
1096
+ yield f"data: {json.dumps(data)}\n\n"
1097
+ data = {
1098
+ "id": f"chatcmpl-{_short_id()}",
1099
+ "object": "chat.completion.chunk",
1100
+ "created": t0_ms // 1000,
1101
+ "model": "topogpt3",
1102
+ "choices": [
1103
+ {"index": 0, "delta": {}, "finish_reason": "stop"}
1104
+ ],
1105
+ }
1106
+ yield f"data: {json.dumps(data)}\n\n"
1107
+ yield "data: [DONE]\n\n"
1108
+
1109
+
1110
+ # ═══════════════════════════════════════════════════════════════════════════
1111
+ # CLI
1112
+ # ═══════════════════════════════════════════════════════════════════════════
1113
+
1114
+
1115
+ def main():
1116
+ ap = argparse.ArgumentParser(
1117
+ description="TopoGPT3 OpenAI-compatible API server (hardened)",
1118
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1119
+ epilog="""\
1120
+ Security
1121
+ --------
1122
+ Keys are loaded from --keys (comma-separated) or the TOPOGPT3_API_KEYS env var.
1123
+ Prefix a key with "admin:" to grant admin-level rate limits.
1124
+
1125
+ Examples:
1126
+ TOPOGPT3_API_KEYS="sk-secret-key,admin:sk-admin-key" python -m topogpt3 api_server
1127
+ python -m topogpt3 api_server --keys "sk-key1,sk-key2" -p 8800
1128
+ python -m topogpt3 api_server --no-auth # DEV ONLY - disables authentication
1129
+ """,
1130
+ )
1131
+ ap.add_argument("--checkpoint", "-c", default="checkpoints_topogpt3/last")
1132
+ ap.add_argument("--port", "-p", type=int, default=8800)
1133
+ ap.add_argument("--host", default="127.0.0.1")
1134
+ ap.add_argument("--device", "-d", default=None)
1135
+ ap.add_argument("--keys", "-k", default=None,
1136
+ help="Comma-separated API keys (or set TOPOGPT3_API_KEYS)")
1137
+ ap.add_argument("--no-auth", action="store_true",
1138
+ help="Disable authentication (DEVELOPMENT ONLY)")
1139
+ ap.add_argument("--verbose", "-v", action="store_true")
1140
+ ap.add_argument("--reload", action="store_true",
1141
+ help="Enable auto-reload for development")
1142
+ args = ap.parse_args()
1143
+
1144
+ _setup_logging(args.verbose)
1145
+
1146
+ os.environ["TOPOGPT3_CHECKPOINT"] = args.checkpoint
1147
+ os.environ["TOPOGPT3_DEVICE"] = _resolve_device(args.device)
1148
+
1149
+ if args.no_auth:
1150
+ os.environ["TOPOGPT3_API_KEYS"] = ""
1151
+ logger.warning("AUTH DISABLED — this server accepts all requests without a key")
1152
+ else:
1153
+ api_keys = args.keys or os.environ.get("TOPOGPT3_API_KEYS", "")
1154
+ if not api_keys.strip():
1155
+ logger.error(
1156
+ "No API keys provided. Set TOPOGPT3_API_KEYS env var "
1157
+ "or pass --keys. Use --no-auth for development only."
1158
+ )
1159
+ sys.exit(1)
1160
+ os.environ["TOPOGPT3_API_KEYS"] = api_keys
1161
+
1162
+ logger.info('{"event":"server_start","host":"%s","port":%d}', args.host, args.port)
1163
+
1164
+ uvicorn.run(
1165
+ "topogpt3.api_server:app" if not args.reload else app,
1166
+ host=args.host,
1167
+ port=args.port,
1168
+ reload=args.reload,
1169
+ log_level="warning",
1170
+ limit_concurrency=MAX_CONCURRENT_REQUESTS,
1171
+ limit_max_requests=None,
1172
+ timeout_keep_alive=30,
1173
+ h11_max_incomplete_event_size=MAX_REQUEST_BODY,
1174
+ )
1175
+
1176
+
1177
+ if __name__ == "__main__":
1178
+ main()