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.
- topoexploit-0.1.0.dist-info/METADATA +523 -0
- topoexploit-0.1.0.dist-info/RECORD +23 -0
- topoexploit-0.1.0.dist-info/WHEEL +5 -0
- topoexploit-0.1.0.dist-info/entry_points.txt +6 -0
- topoexploit-0.1.0.dist-info/licenses/LICENSE +661 -0
- topoexploit-0.1.0.dist-info/top_level.txt +1 -0
- topogpt3/__init__.py +125 -0
- topogpt3/__main__.py +49 -0
- topogpt3/api_server.py +1178 -0
- topogpt3/continuation.py +114 -0
- topogpt3/ewc.py +369 -0
- topogpt3/exploitgym_config.py +130 -0
- topogpt3/exploitgym_loader.py +416 -0
- topogpt3/hodge_cm.py +160 -0
- topogpt3/inference.py +731 -0
- topogpt3/inference_hrm.py +1502 -0
- topogpt3/jlens.py +958 -0
- topogpt3/lens_model.py +389 -0
- topogpt3/merged_config.py +187 -0
- topogpt3/model.py +3824 -0
- topogpt3/structured_output.py +251 -0
- topogpt3/train.py +1939 -0
- topogpt3/vanilla_control.py +240 -0
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()
|