File size: 6,927 Bytes
2a76bb6
 
 
 
9d1a6dc
2a76bb6
 
 
 
9d1a6dc
 
2a76bb6
 
9d1a6dc
2a76bb6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d1a6dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2a76bb6
 
 
9d1a6dc
 
2a76bb6
 
 
9d1a6dc
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
"""内存缓存与限流计数,减少对数据库的依赖。

key 缓存:首次校验后把 key 的校验信息与配额缓存在内存,
后续请求命中缓存即不查库;后台改 key 时清缓存。
RPM/TPM:用内存滑动窗口计数,不查 request_logs 表。
"""
from __future__ import annotations

import asyncio
import hashlib
import hmac
import time
from dataclasses import dataclass
from typing import Dict, List


@dataclass
class CachedKey:
    """key 的缓存校验信息(不含敏感的完整 key)。"""
    id: int
    name: str
    prefix: str
    key_hash: str
    is_active: bool
    expires_at: float | None  # epoch 秒
    allowed_models: list[str] | None
    rpm_limit: int | None
    tpm_limit: int | None
    token_quota: int | None
    tokens_used: int
    # 负载快照时间,用于判断是否需要回库刷新 tokens_used
    loaded_at: float


class KeyCache:
    """按 prefix 索引的 key 内存缓存。"""

    def __init__(self) -> None:
        self._by_prefix: Dict[str, CachedKey] = {}
        self._by_id: Dict[int, str] = {}  # id -> prefix,用于按 id 失效
        self._lock = asyncio.Lock()

    def get(self, prefix: str) -> CachedKey | None:
        return self._by_prefix.get(prefix)

    async def put(self, k: CachedKey) -> None:
        async with self._lock:
            self._by_prefix[k.prefix] = k
            self._by_id[k.id] = k.prefix

    async def invalidate_id(self, key_id: int) -> None:
        """按 id 失效(后台改/删 key 时调用)。"""
        async with self._lock:
            prefix = self._by_id.pop(key_id, None)
            if prefix:
                self._by_prefix.pop(prefix, None)

    async def invalidate_prefix(self, prefix: str) -> None:
        async with self._lock:
            ck = self._by_prefix.pop(prefix, None)
            if ck:
                self._by_id.pop(ck.id, None)

    async def clear(self) -> None:
        async with self._lock:
            self._by_prefix.clear()
            self._by_id.clear()

    async def add_usage(self, key_id: int, tokens: int) -> None:
        """成功转发后把用量累加到内存缓存(不回库),保持配额判断近实时。"""
        async with self._lock:
            prefix = self._by_id.get(key_id)
            if prefix:
                ck = self._by_prefix.get(prefix)
                if ck:
                    ck.tokens_used += tokens


class RPMCounter:
    """内存滑动窗口 RPM 计数,按 key_id 维度。"""

    def __init__(self, window: float = 60.0) -> None:
        self._window = window
        self._hits: Dict[int, List[float]] = {}
        self._lock = asyncio.Lock()

    async def hit_and_check(self, key_id: int, limit: int | None) -> bool:
        """记录一次命中并返回是否在限额内(True=放行,False=超限)。"""
        if not limit:
            return True
        now = time.time()
        cutoff = now - self._window
        async with self._lock:
            hits = self._hits.get(key_id)
            if hits is None:
                hits = []
                self._hits[key_id] = hits
            # 清掉过期命中
            hits[:] = [t for t in hits if t > cutoff]
            if len(hits) >= limit:
                return False
            hits.append(now)
            return True

    async def gc(self) -> None:
        """清理已无命中的 key 条目,避免长跑时 _hits 缓慢膨胀。"""
        cutoff = time.time() - self._window
        async with self._lock:
            for kid in [kid for kid, hits in self._hits.items() if not hits or hits[-1] <= cutoff]:
                self._hits.pop(kid, None)


class TPMCounter:
    """内存滑动窗口 TPM 计数,按 key_id 维度记录窗口内 token 消耗。

    请求前 check:窗口内累计 tokens >= limit 即拒绝;
    请求完成后 record 实际用量。在途请求的 token 不可预知,
    因此 TPM 是软限流(防持续超速,不防瞬时超额)。
    """

    def __init__(self, window: float = 60.0) -> None:
        self._window = window
        self._recs: Dict[int, List[tuple[float, int]]] = {}
        self._lock = asyncio.Lock()

    async def check(self, key_id: int, limit: int | None) -> bool:
        """检查窗口内已消耗 tokens 是否已达上限(True=放行)。顺带清理过期记录。"""
        if not limit:
            return True
        cutoff = time.time() - self._window
        async with self._lock:
            recs = self._recs.get(key_id)
            if not recs:
                self._recs.pop(key_id, None)  # 顺带清理空条目
                return True
            recs[:] = [(t, n) for t, n in recs if t > cutoff]
            if not recs:
                self._recs.pop(key_id, None)
                return True
            return sum(n for _, n in recs) < limit

    async def record(self, key_id: int, tokens: int) -> None:
        """请求完成后记录实际 token 消耗。"""
        if tokens <= 0:
            return
        async with self._lock:
            self._recs.setdefault(key_id, []).append((time.time(), tokens))


class AttemptLimiter:
    """通用失败尝试限流(如后台登录防爆破):window 秒内失败超过 max_attempts 次即拒绝。"""

    def __init__(self, window: float = 600.0, max_attempts: int = 10) -> None:
        self._window = window
        self._max = max_attempts
        self._fails: Dict[str, List[float]] = {}
        self._lock = asyncio.Lock()

    async def allowed(self, ident: str) -> bool:
        """只检查不记录:是否已被限流(True=未被限流)。"""
        cutoff = time.time() - self._window
        async with self._lock:
            fails = self._fails.get(ident)
            if not fails:
                self._fails.pop(ident, None)
                return True
            fails[:] = [t for t in fails if t > cutoff]
            return len(fails) < self._max

    async def hit(self, ident: str) -> bool:
        """记录一次失败并返回是否仍在限额内(True=允许继续尝试,False=已锁定)。"""
        now = time.time()
        cutoff = now - self._window
        async with self._lock:
            fails = self._fails.setdefault(ident, [])
            fails[:] = [t for t in fails if t > cutoff]
            if len(fails) >= self._max:
                return False
            fails.append(now)
            return True

    async def reset(self, ident: str) -> None:
        """登录成功后清除失败计数。"""
        async with self._lock:
            self._fails.pop(ident, None)


key_cache = KeyCache()
rpm_counter = RPMCounter()
tpm_counter = TPMCounter()
login_limiter = AttemptLimiter()


def verify_key_hash(raw: str, digest: str) -> bool:
    """常数时间比较哈希,避免前缀时序侧信道。"""
    return hmac.compare_digest(hashlib.sha256(raw.encode()).hexdigest(), digest)