rules.js
3.45 KB
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
/**
* 规则纯函数:校验规则表、按 (endpoint, by) 选中规则、生成稳定 ruleId。
*
* 零 IO、不碰桶数学、不碰存储。规则形态见 §3:
* `{ endpoint, by, capacity, refillPerSec }`。一期 `by ∈ {'ip','anon'}`。
*
* @typedef {Object} RateRule
* @property {string} endpoint 端点类别,如 'llm' | 'tts' | 'image'
* @property {'ip'|'anon'} by 一期主体维度
* @property {number} capacity 桶容量(突发上限),> 0
* @property {number} refillPerSec 每秒补充令牌数(稳态速率),>= 0
*/
import { RateLimitConfigError } from './errors.js'
/** 一期合法的主体维度。二期扩 'user' 等只动这里与 key.js。 */
export const ALLOWED_BY = ['ip', 'anon']
/**
* 生成规则的稳定标识,用于决策回传与日志定位。形如 `llm:ip`。
* @param {{ endpoint: string, by: string }} rule
* @returns {string}
*/
export function ruleIdOf(rule) {
return `${rule.endpoint}:${rule.by}`
}
/**
* 校验整张规则表,非法立即抛 `RateLimitConfigError`。
* 校验项:非空数组、每条字段类型/取值合法(capacity>0、refillPerSec>=0、
* by 在白名单、endpoint 非空字符串)、ruleId 不重复(同 endpoint+by 只能配一条)。
* @param {unknown} rules
* @returns {RateRule[]} 原样返回(校验通过)
* @throws {RateLimitConfigError}
*/
export function validateRules(rules) {
if (!Array.isArray(rules) || rules.length === 0) {
throw new RateLimitConfigError('rules 必须是非空数组')
}
const seen = new Set()
for (const r of rules) {
if (!r || typeof r !== 'object') {
throw new RateLimitConfigError('rule 必须是对象')
}
if (typeof r.endpoint !== 'string' || r.endpoint.length === 0) {
throw new RateLimitConfigError('rule.endpoint 必须是非空字符串')
}
if (!ALLOWED_BY.includes(r.by)) {
throw new RateLimitConfigError(
`rule.by 非法: ${String(r.by)}(一期仅支持 ${ALLOWED_BY.join('/')})`,
)
}
if (typeof r.capacity !== 'number' || !(r.capacity > 0) || !Number.isFinite(r.capacity)) {
throw new RateLimitConfigError(`rule.capacity 必须是正有限数: ${ruleIdOf(r)}`)
}
if (
typeof r.refillPerSec !== 'number' ||
r.refillPerSec < 0 ||
!Number.isFinite(r.refillPerSec)
) {
throw new RateLimitConfigError(`rule.refillPerSec 必须是非负有限数: ${ruleIdOf(r)}`)
}
const id = ruleIdOf(r)
if (seen.has(id)) {
throw new RateLimitConfigError(`规则重复: ${id}(同 endpoint+by 只能配一条)`)
}
seen.add(id)
}
return /** @type {RateRule[]} */ (rules)
}
/**
* 把规则表编入按 ruleId 索引的 Map,供 limiter 选规则。
* 假定 `rules` 已通过 `validateRules`。
* @param {RateRule[]} rules
* @returns {Map<string, RateRule>}
*/
export function indexRules(rules) {
const m = new Map()
for (const r of rules) m.set(ruleIdOf(r), r)
return m
}
/**
* 在已编入的规则集中选中匹配 `endpoint` 的规则。
* 一期一个 endpoint 只配一条规则(其 by 维度即该端点的限流维度),故按
* endpoint 唯一选中;找不到返回 null(调用方决定 fail-open 还是 fail-closed)。
* @param {Map<string, RateRule>} indexed
* @param {string} endpoint
* @returns {RateRule | null}
*/
export function selectRule(indexed, endpoint) {
for (const rule of indexed.values()) {
if (rule.endpoint === endpoint) return rule
}
return null
}