cartethyia 2.6.7 → 2.7.0
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.
- package/.env.example +106 -113
- package/dist/dashboard/assets/{BannedPage-Dskhyp0l.js → BannedPage-P76QLGJA.js} +1 -1
- package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage--SJhk7qQ.js → CliToolDetailPage-DdBggJS9.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-C203VT-c.js → CliToolsPage-aN1iXhhP.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BcS7kGG1.js → CombosPage-DE-F1nxZ.js} +1 -1
- package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-ChmiSLwO.js → ConsoleLogPage-5SpmfLyw.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-uujbBYwC.js → CustomizationPage-3CUxMy2X.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-fKAWd-OQ.js → HealthEventsModal-BkmJQKC0.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-DoOUthbG.js → LoginPage-B_0FUKWc.js} +1 -1
- package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-CA1kLs45.js → ModelPicker-DKVslqG3.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +1 -0
- package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
- package/dist/dashboard/assets/{ProviderDetailPage-CFu1qGcT.js → ProviderDetailPage-CEJw6Guo.js} +3 -3
- package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-CnlJZbxF.js → ProvidersPage-CEplAR-i.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-B9mBTweh.js → ProxyPage-CAN7hy0f.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-BueSWX5D.js → QuotaPage-BjldQwIU.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js +1 -0
- package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-DQ9oDRpi.js → SetupPage-_bCaTM0v.js} +1 -1
- package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-DrKeMcGN.js → SortableList-fI7JkdIB.js} +1 -1
- package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-qjuYtccc.js → StudioPage-CHVZqskZ.js} +1 -1
- package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-DiFBymHj.js +56 -0
- package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-C23S9sgp.js → api-keys-DFY_fZU4.js} +1 -1
- package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
- package/dist/dashboard/assets/app-BOJ1_xUB.css +2 -0
- package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
- package/dist/dashboard/assets/{assert-DU6qrMA_.js → assert-CwdnZARt.js} +1 -1
- package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
- package/dist/dashboard/assets/{badge-Bkz_-RrM.js → badge-Dr-uf0Au.js} +1 -1
- package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
- package/dist/dashboard/assets/{banner-C-7y3VKV.js → banner-BddmMdoS.js} +1 -1
- package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
- package/dist/dashboard/assets/{card-BOPBuBzD.js → card-CK0PXWlX.js} +1 -1
- package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-BaFUuFFF.js → cli-tools-CcB6mGh2.js} +1 -1
- package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-e-Mlk0FH.js → clipboard-button-DGb2P9Yx.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-Iec_ywbD.js → dialog-LkfuL50u.js} +1 -1
- package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-Bs8MIIHc.js → drawer-ZbEZ_84c.js} +1 -1
- package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
- package/dist/dashboard/assets/{entry-DcobaKFu.js → entry-BJfsVMvA.js} +1 -1
- package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
- package/dist/dashboard/assets/{entry-DOddwoc0.js → entry-CWBY5A0f.js} +3 -3
- package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BRszm4_Z.js → entry-CvC1-Zqx.js} +1 -1
- package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-BuDsrsPn.js → github-badge-BxpznFLt.js} +1 -1
- package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
- package/dist/dashboard/assets/{index-BeLRhsmt.js → index-CDzP3ovJ.js} +2 -2
- package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
- package/dist/dashboard/assets/{inline-DToMICwl.js → inline-Ae13MQ5w.js} +1 -1
- package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
- package/dist/dashboard/assets/{input-WlyHWeG2.js → input-D23fo7cp.js} +1 -1
- package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
- package/dist/dashboard/assets/{layout-DaJ5baJr.js → layout-CSsxjj5P.js} +1 -1
- package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-B3oSK13z.js → list-row-dmbwZMDY.js} +1 -1
- package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
- package/dist/dashboard/assets/{live-ta6GMb-8.js → live-BG1oUsZZ.js} +1 -1
- package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
- package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +1 -0
- package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-g_fduiNw.js → mutation-DSF-e4ho.js} +1 -1
- package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-Dulqgys5.js → numbers-Bm_aECY7.js} +1 -1
- package/dist/dashboard/assets/{numbers-Dulqgys5.js.gz → numbers-Bm_aECY7.js.gz} +0 -0
- package/dist/dashboard/assets/{query-keys-UETqfytp.js → query-keys-CF56nYIV.js} +1 -1
- package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
- package/dist/dashboard/assets/{routing-BPUo-W8d.js → routing-CeA71n7G.js} +1 -1
- package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
- package/dist/dashboard/assets/{select-BfZ3SCTM.js → select-KIT0rC6G.js} +1 -1
- package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
- package/dist/dashboard/assets/settings-B_2gkePf.js +1 -0
- package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
- package/dist/dashboard/assets/{stack-B1w5BM0u.js → stack-D7WOSzcX.js} +1 -1
- package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
- package/dist/dashboard/assets/{state-DhZBfWgx.js → state-BBxtKZOQ.js} +1 -1
- package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
- package/dist/dashboard/assets/{switch-yD7D99Lg.js → switch-DNS_D5Ea.js} +1 -1
- package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-EDFRkn1i.js → useMutation-BgoxdoMk.js} +1 -1
- package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +51 -51
- package/package.json +96 -96
- package/src/boot/app.ts +714 -722
- package/src/boot/dependencies.ts +729 -750
- package/src/config/resources.ts +70 -137
- package/src/config/security.ts +37 -73
- package/src/config/spec.ts +358 -378
- package/src/console/console-router.ts +225 -233
- package/src/console/domain-registration.ts +381 -391
- package/src/console/remote-support/contracts.ts +3 -1
- package/src/console/remote-support/service.ts +115 -6
- package/src/main.ts +167 -169
- package/src/observability/metrics.ts +467 -457
- package/src/observability/runtime-metrics.ts +269 -254
- package/src/protocol/gateway-error.ts +232 -233
- package/src/providers/integrations/commandcode/commandcode.ts +134 -104
- package/src/providers/integrations/commandcode/discovery.ts +6 -5
- package/src/providers/integrations/qoder/qoder.ts +597 -23
- package/src/security/abuse.ts +17 -0
- package/src/transport/guards/auth-guard.ts +109 -127
- package/src/transport/guards/pipeline.ts +220 -226
- package/src/transport/guards/request-context.ts +238 -318
- package/src/transport/request/inflight.ts +21 -2
- package/dist/dashboard/assets/BannedPage-Dskhyp0l.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage--SJhk7qQ.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-C203VT-c.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BcS7kGG1.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-ChmiSLwO.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-uujbBYwC.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-fKAWd-OQ.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DoOUthbG.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-CA1kLs45.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js +0 -1
- package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CFu1qGcT.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-CnlJZbxF.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-B9mBTweh.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BueSWX5D.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-B0x00MVY.js +0 -1
- package/dist/dashboard/assets/SettingsPage-B0x00MVY.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-DQ9oDRpi.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DrKeMcGN.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-qjuYtccc.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-Be7aNviQ.js +0 -56
- package/dist/dashboard/assets/UsagePage-Be7aNviQ.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
- package/dist/dashboard/assets/app-BUSiYL6F.css +0 -2
- package/dist/dashboard/assets/app-BUSiYL6F.css.gz +0 -0
- package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
- package/dist/dashboard/assets/badge-Bkz_-RrM.js.gz +0 -0
- package/dist/dashboard/assets/banner-C-7y3VKV.js.gz +0 -0
- package/dist/dashboard/assets/card-BOPBuBzD.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-e-Mlk0FH.js.gz +0 -0
- package/dist/dashboard/assets/dialog-Iec_ywbD.js.gz +0 -0
- package/dist/dashboard/assets/drawer-Bs8MIIHc.js.gz +0 -0
- package/dist/dashboard/assets/entry-BRszm4_Z.js.gz +0 -0
- package/dist/dashboard/assets/entry-DOddwoc0.js.gz +0 -0
- package/dist/dashboard/assets/entry-DcobaKFu.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-BuDsrsPn.js.gz +0 -0
- package/dist/dashboard/assets/index-BeLRhsmt.js.gz +0 -0
- package/dist/dashboard/assets/inline-DToMICwl.js.gz +0 -0
- package/dist/dashboard/assets/input-WlyHWeG2.js.gz +0 -0
- package/dist/dashboard/assets/layout-DaJ5baJr.js.gz +0 -0
- package/dist/dashboard/assets/list-row-B3oSK13z.js.gz +0 -0
- package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js +0 -1
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
- package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
- package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
- package/dist/dashboard/assets/select-BfZ3SCTM.js.gz +0 -0
- package/dist/dashboard/assets/settings--3hxSyry.js +0 -1
- package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
- package/dist/dashboard/assets/stack-B1w5BM0u.js.gz +0 -0
- package/dist/dashboard/assets/state-DhZBfWgx.js.gz +0 -0
- package/dist/dashboard/assets/switch-yD7D99Lg.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
- package/src/console/admin/model-abuse/contracts.ts +0 -38
- package/src/console/admin/model-abuse/routes.ts +0 -97
- package/src/security/model-abuse.ts +0 -704
- package/src/transport/guards/concurrency-guard.ts +0 -125
- /package/dist/dashboard/assets/{app-MdVfwraB.js → app-CaXZ9SCn.js} +0 -0
- /package/dist/dashboard/assets/{app-MdVfwraB.js.gz → app-CaXZ9SCn.js.gz} +0 -0
|
@@ -1,704 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Model-abuse strikes: a graduated response to a client that repeatedly asks
|
|
3
|
-
* for a model it may not use.
|
|
4
|
-
*
|
|
5
|
-
* A gateway request names a model; when that name is outside the key's
|
|
6
|
-
* allowlist, denylisted, or resolves to nothing, the request is rejected 404
|
|
7
|
-
* `model_not_found`. That rejection is a normal answer to one mistake — but a
|
|
8
|
-
* client that keeps hitting it is not making a mistake, it is probing, and it
|
|
9
|
-
* costs the operator real work: every attempt lands as a failed row in the
|
|
10
|
-
* console and the share page, burying the traffic that matters.
|
|
11
|
-
*
|
|
12
|
-
* So the rejection escalates. Each invalid-model attempt inside the sliding
|
|
13
|
-
* window records a timestamp against the caller's client IP; crossing the
|
|
14
|
-
* threshold bans that IP. A valid request does not reset the window. Attempts
|
|
15
|
-
* older than the window stop counting, so old probes do not accumulate forever.
|
|
16
|
-
*
|
|
17
|
-
* **The ban is keyed on the client address and the API key that was used.**
|
|
18
|
-
*
|
|
19
|
-
* Each half alone was the wrong trade. The address alone: a key is shared by
|
|
20
|
-
* everyone behind a template (every recipient of a share link, every member of
|
|
21
|
-
* a team), so banning the address punishes callers that did nothing. The key
|
|
22
|
-
* alone: the address that actually probed mints a fresh key and carries on.
|
|
23
|
-
* Together they block the caller that earned it and nobody else.
|
|
24
|
-
*
|
|
25
|
-
* **A known gap, deliberately left open.** A share key's children are issued per
|
|
26
|
-
* recipient, so banning `address|child` leaves that recipient able to enroll a
|
|
27
|
-
* *new* child key from the same address and continue. Closing it by banning the
|
|
28
|
-
* address (or the share template) would take down every other recipient behind
|
|
29
|
-
* the same NAT — exactly the collateral this scoping exists to avoid. The
|
|
30
|
-
* console's "ban every key from this IP" is the operator's opt-in for the case
|
|
31
|
-
* where the address, not the credential, is the problem.
|
|
32
|
-
*
|
|
33
|
-
* **A ban lapses after its TTL** (`CARTETHYIA_MODEL_BAN_TTL_MS` for the
|
|
34
|
-
* graduated path, or the duration an operator chose) rather than staying until
|
|
35
|
-
* someone lifts it, so a false positive heals without human intervention. The
|
|
36
|
-
* console can still lift one early.
|
|
37
|
-
*
|
|
38
|
-
* Fail-open on store outage: unlike admission (where admitting an unauthorized
|
|
39
|
-
* request is worse than refusing a valid one), a strike layer that cannot read
|
|
40
|
-
* its counter must not refuse a legitimate request. A store error is swallowed
|
|
41
|
-
* and the request proceeds; the worst case is a missed strike, never a blocked
|
|
42
|
-
* client.
|
|
43
|
-
*/
|
|
44
|
-
import { GatewayError } from "../protocol/gateway-error";
|
|
45
|
-
import { redisEvalNumber, redisEvalTuple, type RedisClient } from "../persistence/redis";
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* A ban is keyed on the address **and** the API key that was used, so banning
|
|
49
|
-
* one caller behind a shared address does not take down everyone else on it.
|
|
50
|
-
*
|
|
51
|
-
* The pair is stored as one opaque string rather than two fields because both
|
|
52
|
-
* stores address a ban by a single member: Redis keeps bans in one sorted set
|
|
53
|
-
* scored by expiry, and a composite key is the only way to keep that a single
|
|
54
|
-
* O(1) lookup. `|` is the separator because neither an IP nor a uuid can
|
|
55
|
-
* contain it, so the split is unambiguous.
|
|
56
|
-
*/
|
|
57
|
-
export function banIdentity(ip: string, apiKeyId?: string | null): string {
|
|
58
|
-
const key = apiKeyId ?? "";
|
|
59
|
-
// No key is the *bare address*, not `address|` with an empty tail: a ban
|
|
60
|
-
// written before scoping existed is exactly this string, and a rolling
|
|
61
|
-
// deploy has to read those back as what they always were — a ban on every
|
|
62
|
-
// key from that address. Giving the two shapes different spellings would
|
|
63
|
-
// silently un-ban every caller the running deployment is blocking.
|
|
64
|
-
return key.length > 0 ? `${ip}|${key}` : ip;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* The address-wide member for `ip`: the same shape, with no key.
|
|
69
|
-
*
|
|
70
|
-
* A ban recorded under it blocks every key used from that address. It is what
|
|
71
|
-
* an operator picks when the address itself is the problem — and, because it
|
|
72
|
-
* is the bare address, it is also the member every pre-scoping ban already
|
|
73
|
-
* uses.
|
|
74
|
-
*/
|
|
75
|
-
export function addressBanIdentity(ip: string): string {
|
|
76
|
-
return ip;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Splits a ban member back into the address and the key it names. */
|
|
80
|
-
export function parseBanIdentity(
|
|
81
|
-
identity: string,
|
|
82
|
-
): { readonly ip: string; readonly apiKeyId: string | null } {
|
|
83
|
-
const at = identity.indexOf("|");
|
|
84
|
-
// A member with no separator is the bare address: a ban on every key from it,
|
|
85
|
-
// whether an operator asked for that or the ban predates scoping.
|
|
86
|
-
if (at < 0) return { ip: identity, apiKeyId: null };
|
|
87
|
-
const key = identity.slice(at + 1);
|
|
88
|
-
return { ip: identity.slice(0, at), apiKeyId: key.length > 0 ? key : null };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* The TTL behind the "Permanent" choice.
|
|
93
|
-
*
|
|
94
|
-
* Not literally infinite, because both stores lapse a ban by comparing its
|
|
95
|
-
* expiry to now — an unbounded ban would need a second, different mechanism
|
|
96
|
-
* and a second way to get it wrong. Fifty years is past the point where
|
|
97
|
-
* "lapses on its own" means anything, and the ban is still liftable from the
|
|
98
|
-
* console, which is the property that actually matters: a ban an operator
|
|
99
|
-
* cannot undo is a trap, not a deterrent.
|
|
100
|
-
*/
|
|
101
|
-
export const PERMANENT_BAN_TTL_MS = 50 * 365 * 24 * 60 * 60_000;
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Whether a ban's remaining time can only be the permanent choice.
|
|
105
|
-
*
|
|
106
|
-
* Derived rather than stored, because the stores carry a ban as one scored
|
|
107
|
-
* member with no room for a flag, and a second parallel structure would be a
|
|
108
|
-
* second thing to keep in sync. The longest chooseable non-permanent ban is a
|
|
109
|
-
* week, so a remaining time measured in years has exactly one origin.
|
|
110
|
-
*/
|
|
111
|
-
export function isPermanentBan(expiresAt: number, now: number): boolean {
|
|
112
|
-
return expiresAt - now > 30 * 24 * 60 * 60_000;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** A ban row, for the console. */
|
|
116
|
-
export interface ModelAbuseBan {
|
|
117
|
-
/** The client address the ban is keyed on. */
|
|
118
|
-
readonly ip: string;
|
|
119
|
-
/**
|
|
120
|
-
* The API key the ban is scoped to, or `null` for every key from that
|
|
121
|
-
* address. `null` is the blunt instrument: it is what the graduated strike
|
|
122
|
-
* path used to always do, and what an operator still picks when the address
|
|
123
|
-
* itself is the problem.
|
|
124
|
-
*/
|
|
125
|
-
readonly apiKeyId: string | null;
|
|
126
|
-
/** Model-abuse policy that triggered this ban. */
|
|
127
|
-
readonly reason: "repeated_invalid_model_requests";
|
|
128
|
-
/** Epoch ms the ban lapses on its own. */
|
|
129
|
-
readonly expiresAt: number;
|
|
130
|
-
/** Whether this is the "Permanent" choice rather than a timed one. */
|
|
131
|
-
readonly permanent: boolean;
|
|
132
|
-
/** The opaque member the store addresses this ban by, for `unban`. */
|
|
133
|
-
readonly identity: string;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** One strike decision, as the store sees it. */
|
|
137
|
-
export interface ModelAbuseAttempt {
|
|
138
|
-
readonly ip: string;
|
|
139
|
-
/**
|
|
140
|
-
* The key this attempt authenticated with, if one was resolved. Scopes both
|
|
141
|
-
* the strike window and the ban it escalates to, so a key shared by several
|
|
142
|
-
* callers does not let one of them spend another's budget.
|
|
143
|
-
*/
|
|
144
|
-
readonly apiKeyId?: string | null;
|
|
145
|
-
/** Whether this attempt named a model the key may use. */
|
|
146
|
-
readonly valid: boolean;
|
|
147
|
-
/** Invalid attempts inside the window that trigger a ban. */
|
|
148
|
-
readonly threshold: number;
|
|
149
|
-
/** Sliding window (ms) that keeps an invalid attempt countable. */
|
|
150
|
-
readonly windowMs: number;
|
|
151
|
-
/** How long (ms) a ban lasts before it lapses on its own. */
|
|
152
|
-
readonly banTtlMs: number;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** What one strike decision decided. */
|
|
156
|
-
export interface ModelAbuseOutcome {
|
|
157
|
-
/** The IP is banned, now or already. */
|
|
158
|
-
readonly banned: boolean;
|
|
159
|
-
/** Invalid attempts still inside the window, after this one. `0` when valid. */
|
|
160
|
-
readonly strikes: number;
|
|
161
|
-
/** This attempt crossed the threshold and recorded the ban. */
|
|
162
|
-
readonly bannedNow: boolean;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* The `{banned, strikes, bannedNow}` tuple the script returns. A value of the
|
|
167
|
-
* wrong length or with a non-finite member means the script and its caller
|
|
168
|
-
* drifted; reading it as a decision would ban or admit on garbage, so it
|
|
169
|
-
* throws — the service maps that to a swallowed fail-open, never a rejection.
|
|
170
|
-
*/
|
|
171
|
-
function decodeOutcome(raw: readonly unknown[]): ModelAbuseOutcome {
|
|
172
|
-
if (raw.length !== 3) {
|
|
173
|
-
throw new Error(`[model-abuse] script returned ${raw.length} elements, expected 3`);
|
|
174
|
-
}
|
|
175
|
-
const [bannedRaw, strikesRaw, bannedNowRaw] = raw.map((value) => Number(value));
|
|
176
|
-
const banned = bannedRaw ?? NaN;
|
|
177
|
-
const strikes = strikesRaw ?? NaN;
|
|
178
|
-
const bannedNow = bannedNowRaw ?? NaN;
|
|
179
|
-
if (!Number.isFinite(banned) || !Number.isFinite(strikes) || !Number.isFinite(bannedNow)) {
|
|
180
|
-
throw new Error(`[model-abuse] script returned a non-finite value: ${String(raw)}`);
|
|
181
|
-
}
|
|
182
|
-
return { banned: banned === 1 || bannedNow === 1, strikes, bannedNow: bannedNow === 1 };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export interface ModelAbuseStore {
|
|
186
|
-
/**
|
|
187
|
-
* One atomic strike decision: reads the ban and records an invalid attempt
|
|
188
|
-
* in the caller's sliding window. A valid attempt changes nothing.
|
|
189
|
-
*
|
|
190
|
-
* The window is keyed on the address **and** the key, so two callers sharing
|
|
191
|
-
* one address each get their own budget: a strike is something one caller
|
|
192
|
-
* did, and merging them let a busy caller escalate a ban onto a quiet one.
|
|
193
|
-
*/
|
|
194
|
-
record(attempt: ModelAbuseAttempt): Promise<ModelAbuseOutcome>;
|
|
195
|
-
/**
|
|
196
|
-
* O(1) ban lookup for the pre-parse gate, by the opaque member
|
|
197
|
-
* {@link banIdentity} builds.
|
|
198
|
-
*
|
|
199
|
-
* True when that member is banned **or** the address-wide member is — a ban
|
|
200
|
-
* scoped to no key blocks every key from the address, and a ban written
|
|
201
|
-
* before scoping existed is such a member. Both are read here rather than at
|
|
202
|
-
* the call site because the store owns where a ban lives, and a second
|
|
203
|
-
* lookup outside it would be a second round trip per request on Redis.
|
|
204
|
-
*/
|
|
205
|
-
isBanned(identity: string): Promise<boolean>;
|
|
206
|
-
/** Every active ban, for the console. */
|
|
207
|
-
listBans(): Promise<readonly ModelAbuseBan[]>;
|
|
208
|
-
/** Lifts one ban, by the opaque member the listing reports. */
|
|
209
|
-
unban(identity: string): Promise<boolean>;
|
|
210
|
-
/**
|
|
211
|
-
* Bans one member immediately for `ttlMs`, regardless of strike history.
|
|
212
|
-
* Returns the ban's expiry (epoch ms). The operator's instant-ban action
|
|
213
|
-
* and the graduated strike path share one ban set, so `unban` lifts both.
|
|
214
|
-
*/
|
|
215
|
-
ban(identity: string, ttlMs: number): Promise<number>;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
interface InMemoryOptions {
|
|
219
|
-
readonly maxKeys?: number;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* In-memory strike store: one timestamp list and one ban map.
|
|
224
|
-
*
|
|
225
|
-
* Each invalid attempt keeps its own instant. The count is the number of
|
|
226
|
-
* instants still inside the window, so a valid request cannot reset it and an
|
|
227
|
-
* old attempt leaves on its own. A ban carries the instant it lapses and is
|
|
228
|
-
* swept lazily on the next read of that address. Bounded by `maxKeys` (oldest
|
|
229
|
-
* evicted) so an adversarial fan-out over unique addresses cannot pin memory;
|
|
230
|
-
* the ban map is bounded by the abuse itself, since each entry cost a full
|
|
231
|
-
* threshold of attempts, and entries leave when their TTL lapses.
|
|
232
|
-
*/
|
|
233
|
-
export class InMemoryModelAbuseStore implements ModelAbuseStore {
|
|
234
|
-
private readonly strikes = new Map<string, number[]>();
|
|
235
|
-
private readonly bans = new Map<string, number>();
|
|
236
|
-
private readonly maxKeys: number;
|
|
237
|
-
|
|
238
|
-
constructor(private readonly clock: () => number = () => Date.now(), opts: InMemoryOptions = {}) {
|
|
239
|
-
this.maxKeys = opts.maxKeys ?? 10_000;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
private evictOldest(): void {
|
|
243
|
-
if (this.strikes.size < this.maxKeys) return;
|
|
244
|
-
let toEvict = this.strikes.size - this.maxKeys + 1;
|
|
245
|
-
for (const key of this.strikes.keys()) {
|
|
246
|
-
if (toEvict-- <= 0) break;
|
|
247
|
-
this.strikes.delete(key);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** The live ban's expiry, or `undefined`; an elapsed ban is dropped. */
|
|
252
|
-
private banExpiry(identity: string, now: number): number | undefined {
|
|
253
|
-
const expiresAt = this.bans.get(identity);
|
|
254
|
-
if (expiresAt === undefined) return undefined;
|
|
255
|
-
if (expiresAt <= now) {
|
|
256
|
-
this.bans.delete(identity);
|
|
257
|
-
return undefined;
|
|
258
|
-
}
|
|
259
|
-
return expiresAt;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Whether `identity` is banned, reading both members that can block it.
|
|
264
|
-
*
|
|
265
|
-
* The entry for this exact address+key, and the address-wide entry. The
|
|
266
|
-
* second is what makes a ban scoped to every key from an address still
|
|
267
|
-
* enforced, and what keeps a ban written before scoping existed — whose
|
|
268
|
-
* member is the bare address — from silently lapsing on deploy.
|
|
269
|
-
*/
|
|
270
|
-
private bannedNow(identity: string, now: number): boolean {
|
|
271
|
-
const { ip, apiKeyId } = parseBanIdentity(identity);
|
|
272
|
-
if (this.banExpiry(identity, now) !== undefined) return true;
|
|
273
|
-
if (apiKeyId === null) return false;
|
|
274
|
-
return this.banExpiry(addressBanIdentity(ip), now) !== undefined;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/** Drops attempts older than the window and returns the rest. */
|
|
278
|
-
private liveAttempts(identity: string, now: number, windowMs: number): number[] {
|
|
279
|
-
const cutoff = now - windowMs;
|
|
280
|
-
const existing = this.strikes.get(identity) ?? [];
|
|
281
|
-
// In-place compaction: `.filter` allocated a new array per invalid attempt.
|
|
282
|
-
let write = 0;
|
|
283
|
-
for (let read = 0; read < existing.length; read += 1) {
|
|
284
|
-
if (existing[read]! > cutoff) existing[write++] = existing[read]!;
|
|
285
|
-
}
|
|
286
|
-
existing.length = write;
|
|
287
|
-
if (existing.length === 0) this.strikes.delete(identity);
|
|
288
|
-
else this.strikes.set(identity, existing);
|
|
289
|
-
return existing;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
async record(attempt: ModelAbuseAttempt): Promise<ModelAbuseOutcome> {
|
|
293
|
-
const now = this.clock();
|
|
294
|
-
const { ip, apiKeyId, valid, threshold, windowMs, banTtlMs } = attempt;
|
|
295
|
-
const identity = banIdentity(ip, apiKeyId);
|
|
296
|
-
|
|
297
|
-
if (this.bannedNow(identity, now)) return { banned: true, strikes: 0, bannedNow: false };
|
|
298
|
-
|
|
299
|
-
if (valid) return { banned: false, strikes: 0, bannedNow: false };
|
|
300
|
-
|
|
301
|
-
const live = this.liveAttempts(identity, now, windowMs);
|
|
302
|
-
live.push(now);
|
|
303
|
-
this.evictOldest();
|
|
304
|
-
this.strikes.set(identity, live);
|
|
305
|
-
if (live.length >= threshold) {
|
|
306
|
-
// Scoped to the key that earned it. Banning the address alone would take
|
|
307
|
-
// down every other caller behind the same NAT or share link, none of
|
|
308
|
-
// whom made the invalid requests.
|
|
309
|
-
this.bans.set(identity, now + banTtlMs);
|
|
310
|
-
this.strikes.delete(identity);
|
|
311
|
-
return { banned: true, strikes: live.length, bannedNow: true };
|
|
312
|
-
}
|
|
313
|
-
return { banned: false, strikes: live.length, bannedNow: false };
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
async listBans(): Promise<readonly ModelAbuseBan[]> {
|
|
318
|
-
const now = this.clock();
|
|
319
|
-
const bans: ModelAbuseBan[] = [];
|
|
320
|
-
for (const [identity, expiresAt] of [...this.bans]) {
|
|
321
|
-
if (expiresAt <= now) {
|
|
322
|
-
this.bans.delete(identity);
|
|
323
|
-
continue;
|
|
324
|
-
}
|
|
325
|
-
const { ip, apiKeyId } = parseBanIdentity(identity);
|
|
326
|
-
bans.push({
|
|
327
|
-
ip,
|
|
328
|
-
apiKeyId,
|
|
329
|
-
reason: "repeated_invalid_model_requests",
|
|
330
|
-
expiresAt,
|
|
331
|
-
permanent: isPermanentBan(expiresAt, now),
|
|
332
|
-
identity,
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
return bans;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
async isBanned(identity: string): Promise<boolean> {
|
|
339
|
-
return this.bannedNow(identity, this.clock());
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
async unban(identity: string): Promise<boolean> {
|
|
343
|
-
return this.bans.delete(identity);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
async ban(identity: string, ttlMs: number): Promise<number> {
|
|
347
|
-
const expiresAt = this.clock() + Math.max(1, ttlMs);
|
|
348
|
-
this.bans.set(identity, expiresAt);
|
|
349
|
-
return expiresAt;
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* The one strike decision, as a static Lua script.
|
|
355
|
-
*
|
|
356
|
-
* `KEYS[1]` the attempt sorted set for this address+key, `KEYS[2]` the ban
|
|
357
|
-
* sorted set. `ARGV`: 1 valid(`1`/`0`), 2 windowMs, 3 this caller's ban
|
|
358
|
-
* member, 4 now (epoch ms), 5 threshold, 6 banTtlMs, 7 the address-wide ban
|
|
359
|
-
* member.
|
|
360
|
-
*
|
|
361
|
-
* Returns `{banned, strikes, bannedNow}`. The ban is read first and
|
|
362
|
-
* short-circuits without writing, so a banned caller cannot extend its own
|
|
363
|
-
* window — and it is read for *both* members: the one naming this key, and the
|
|
364
|
-
* address-wide one. The second is what keeps a ban scoped to "every key from
|
|
365
|
-
* this address" enforced, and what keeps a ban written before scoping existed
|
|
366
|
-
* (whose member is the bare address) from lapsing on deploy. Both reads happen
|
|
367
|
-
* inside the script because the gate runs on every `/v1/*` request: two
|
|
368
|
-
* `ZSCORE` calls from the caller would be two round trips per request.
|
|
369
|
-
*
|
|
370
|
-
* Each invalid attempt is one member scored by its instant. Members older than
|
|
371
|
-
* the window are removed before the count, so a valid request does not reset
|
|
372
|
-
* it and an old attempt leaves on its own. A ban is a member of the ban set
|
|
373
|
-
* scored with the instant it lapses. `now` is the caller's clock rather than
|
|
374
|
-
* the server's so the in-memory and Redis stores age a ban identically.
|
|
375
|
-
*/
|
|
376
|
-
const MODEL_ABUSE_SCRIPT = `
|
|
377
|
-
local function liveBan(member, now)
|
|
378
|
-
local score = redis.call('ZSCORE', KEYS[2], member)
|
|
379
|
-
if not score then return false end
|
|
380
|
-
if tonumber(score) > now then return true end
|
|
381
|
-
redis.call('ZREM', KEYS[2], member)
|
|
382
|
-
return false
|
|
383
|
-
end
|
|
384
|
-
local now = tonumber(ARGV[4])
|
|
385
|
-
if liveBan(ARGV[3], now) or liveBan(ARGV[7], now) then
|
|
386
|
-
return {1, 0, 0}
|
|
387
|
-
end
|
|
388
|
-
if ARGV[1] == '1' then
|
|
389
|
-
return {0, 0, 0}
|
|
390
|
-
end
|
|
391
|
-
local windowMs = tonumber(ARGV[2])
|
|
392
|
-
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - windowMs)
|
|
393
|
-
local seq = redis.call('INCR', KEYS[1] .. ':seq')
|
|
394
|
-
redis.call('ZADD', KEYS[1], now, now .. ':' .. seq)
|
|
395
|
-
local count = redis.call('ZCARD', KEYS[1])
|
|
396
|
-
redis.call('PEXPIRE', KEYS[1], windowMs)
|
|
397
|
-
redis.call('PEXPIRE', KEYS[1] .. ':seq', windowMs)
|
|
398
|
-
if count >= tonumber(ARGV[5]) then
|
|
399
|
-
redis.call('ZADD', KEYS[2], now + tonumber(ARGV[6]), ARGV[3])
|
|
400
|
-
redis.call('DEL', KEYS[1], KEYS[1] .. ':seq')
|
|
401
|
-
return {0, count, 1}
|
|
402
|
-
end
|
|
403
|
-
return {0, count, 0}
|
|
404
|
-
`;
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* The pre-parse ban check, as a static Lua script.
|
|
408
|
-
*
|
|
409
|
-
* Reads both members that can block a caller — this address+key, and the
|
|
410
|
-
* address-wide one — and returns `1` or `0`. One script rather than two
|
|
411
|
-
* `ZSCORE` calls from the caller because this runs on every `/v1/*` request,
|
|
412
|
-
* where a second round trip is a second hop on the hot path. It also drops a
|
|
413
|
-
* member it finds elapsed, which is what keeps the ban set from accumulating
|
|
414
|
-
* dead entries no request will ever look up again.
|
|
415
|
-
*/
|
|
416
|
-
const MODEL_ABUSE_BAN_CHECK_SCRIPT = `
|
|
417
|
-
local function liveBan(member, now)
|
|
418
|
-
local score = redis.call('ZSCORE', KEYS[1], member)
|
|
419
|
-
if not score then return false end
|
|
420
|
-
if tonumber(score) > now then return true end
|
|
421
|
-
redis.call('ZREM', KEYS[1], member)
|
|
422
|
-
return false
|
|
423
|
-
end
|
|
424
|
-
local now = tonumber(ARGV[3])
|
|
425
|
-
if liveBan(ARGV[1], now) or liveBan(ARGV[2], now) then
|
|
426
|
-
return 1
|
|
427
|
-
end
|
|
428
|
-
return 0
|
|
429
|
-
`;
|
|
430
|
-
|
|
431
|
-
export class RedisModelAbuseStore implements ModelAbuseStore {
|
|
432
|
-
constructor(
|
|
433
|
-
private readonly redis: RedisClient,
|
|
434
|
-
private readonly clock: () => number = () => Date.now(),
|
|
435
|
-
) {}
|
|
436
|
-
|
|
437
|
-
private strikeKey(identity: string): string {
|
|
438
|
-
return `cartethyia:model-abuse:strike:${identity}`;
|
|
439
|
-
}
|
|
440
|
-
/**
|
|
441
|
-
* The ban index. It is a sorted set scored with each ban's expiry instant,
|
|
442
|
-
* under a name of its own: the previous permanent-ban *set* cannot be read as
|
|
443
|
-
* a sorted set, and reading it anyway would raise WRONGTYPE on every check.
|
|
444
|
-
* A member from before that change is therefore simply gone — the right
|
|
445
|
-
* outcome, since those bans were recorded without an expiry this store cannot
|
|
446
|
-
* invent.
|
|
447
|
-
*
|
|
448
|
-
* The members are now `address|key` composites, and a member with no key
|
|
449
|
-
* blocks every key from its address — which is also how a pre-scoping member
|
|
450
|
-
* reads, since those were the bare address.
|
|
451
|
-
*/
|
|
452
|
-
private banKey(): string {
|
|
453
|
-
return "cartethyia:model-abuse:bans";
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
async record(attempt: ModelAbuseAttempt): Promise<ModelAbuseOutcome> {
|
|
457
|
-
const { ip, apiKeyId, valid, threshold, windowMs, banTtlMs } = attempt;
|
|
458
|
-
const identity = banIdentity(ip, apiKeyId);
|
|
459
|
-
const raw = await redisEvalTuple(
|
|
460
|
-
this.redis,
|
|
461
|
-
MODEL_ABUSE_SCRIPT,
|
|
462
|
-
2,
|
|
463
|
-
this.strikeKey(identity),
|
|
464
|
-
this.banKey(),
|
|
465
|
-
valid ? "1" : "0",
|
|
466
|
-
String(Math.max(1, windowMs)),
|
|
467
|
-
identity,
|
|
468
|
-
String(this.clock()),
|
|
469
|
-
String(threshold),
|
|
470
|
-
String(Math.max(1, banTtlMs)),
|
|
471
|
-
addressBanIdentity(ip),
|
|
472
|
-
);
|
|
473
|
-
return decodeOutcome(raw);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
async listBans(): Promise<readonly ModelAbuseBan[]> {
|
|
477
|
-
const now = this.clock();
|
|
478
|
-
await this.redis.zremrangebyscore(this.banKey(), "-inf", String(now));
|
|
479
|
-
const entries = await this.redis.zrange(this.banKey(), "0", "-1", "WITHSCORES");
|
|
480
|
-
const bans: ModelAbuseBan[] = [];
|
|
481
|
-
for (let index = 0; index + 1 < entries.length; index += 2) {
|
|
482
|
-
const identity = entries[index];
|
|
483
|
-
const score = entries[index + 1];
|
|
484
|
-
if (identity === undefined || score === undefined) break;
|
|
485
|
-
const expiresAt = Number(score);
|
|
486
|
-
if (!Number.isFinite(expiresAt)) continue;
|
|
487
|
-
const { ip, apiKeyId } = parseBanIdentity(identity);
|
|
488
|
-
bans.push({
|
|
489
|
-
ip,
|
|
490
|
-
apiKeyId,
|
|
491
|
-
reason: "repeated_invalid_model_requests",
|
|
492
|
-
expiresAt,
|
|
493
|
-
permanent: isPermanentBan(expiresAt, now),
|
|
494
|
-
identity,
|
|
495
|
-
});
|
|
496
|
-
}
|
|
497
|
-
return bans;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
async isBanned(identity: string): Promise<boolean> {
|
|
501
|
-
const { ip, apiKeyId } = parseBanIdentity(identity);
|
|
502
|
-
const value = await redisEvalNumber(
|
|
503
|
-
this.redis,
|
|
504
|
-
MODEL_ABUSE_BAN_CHECK_SCRIPT,
|
|
505
|
-
1,
|
|
506
|
-
this.banKey(),
|
|
507
|
-
identity,
|
|
508
|
-
// A ban scoped to no key is its own address-wide member, so the second
|
|
509
|
-
// read is the same member — harmless, and it keeps the script one shape.
|
|
510
|
-
apiKeyId === null ? identity : addressBanIdentity(ip),
|
|
511
|
-
String(this.clock()),
|
|
512
|
-
);
|
|
513
|
-
return value === 1;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
async unban(identity: string): Promise<boolean> {
|
|
517
|
-
const removed = await this.redis.zrem(this.banKey(), identity);
|
|
518
|
-
return Number(removed) > 0;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
async ban(identity: string, ttlMs: number): Promise<number> {
|
|
522
|
-
const expiresAt = this.clock() + Math.max(1, ttlMs);
|
|
523
|
-
await this.redis.zadd(this.banKey(), String(expiresAt), identity);
|
|
524
|
-
return expiresAt;
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
export interface ModelStrikeOptions {
|
|
529
|
-
/** Invalid-model attempts inside the window that trigger a ban. Default 10. */
|
|
530
|
-
readonly threshold?: number;
|
|
531
|
-
/** Sliding window (ms) that keeps an invalid attempt countable. Default 30 minutes. */
|
|
532
|
-
readonly windowMs?: number;
|
|
533
|
-
/** How long (ms) a graduated ban lasts before it lapses. Default 6 hours. */
|
|
534
|
-
readonly banTtlMs?: number;
|
|
535
|
-
/**
|
|
536
|
-
* The longest ban this service will record, however long a caller asks for.
|
|
537
|
-
* Default one week. The "Permanent" choice is not exempt: it is a very long
|
|
538
|
-
* TTL, not an unbounded one, and this is the bound that keeps a typo in a
|
|
539
|
-
* duration from parking a caller for a decade.
|
|
540
|
-
*/
|
|
541
|
-
readonly maxBanTtlMs?: number;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
/**
|
|
545
|
-
* Graduated model-abuse strikes over a store.
|
|
546
|
-
*
|
|
547
|
-
* `noteInvalid` records one invalid attempt in the sliding window. `noteValid`
|
|
548
|
-
* does not reset that window. `listBans`/`unban` are the console's escape hatch.
|
|
549
|
-
*/
|
|
550
|
-
export class ModelStrikeService {
|
|
551
|
-
private readonly threshold: number;
|
|
552
|
-
private readonly windowMs: number;
|
|
553
|
-
private readonly _banTtlMs: number;
|
|
554
|
-
private readonly _maxBanTtlMs: number;
|
|
555
|
-
|
|
556
|
-
constructor(private readonly store: ModelAbuseStore, opts: ModelStrikeOptions = {}) {
|
|
557
|
-
this.threshold = Math.max(1, opts.threshold ?? 10);
|
|
558
|
-
this.windowMs = Math.max(1, opts.windowMs ?? 30 * 60_000);
|
|
559
|
-
this._banTtlMs = Math.max(1, opts.banTtlMs ?? 6 * 60 * 60_000);
|
|
560
|
-
// The operator-facing ceiling defaults to a week — the same upper bound the
|
|
561
|
-
// deployment env var accepts — so a request cannot ask for a ban longer
|
|
562
|
-
// than the longest one an operator can configure by hand.
|
|
563
|
-
this._maxBanTtlMs = Math.max(
|
|
564
|
-
this._banTtlMs,
|
|
565
|
-
Math.max(1, opts.maxBanTtlMs ?? 7 * 24 * 60 * 60_000),
|
|
566
|
-
);
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
/** The ban threshold, for an operator-facing warning message. */
|
|
570
|
-
get limit(): number {
|
|
571
|
-
return this.threshold;
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
get banTtlMs(): number {
|
|
575
|
-
return this._banTtlMs;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/** The longest ban this service will record, however long one is asked for. */
|
|
579
|
-
get maxBanTtlMs(): number {
|
|
580
|
-
return this._maxBanTtlMs;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
/**
|
|
584
|
-
* Read-only ban lookup for the pre-parse gate. Throws only on store outage.
|
|
585
|
-
*
|
|
586
|
-
* `apiKeyId` decides how narrow the answer is. Absent (an unauthenticated
|
|
587
|
-
* attempt, or a composition that does not carry the key) asks the blunt
|
|
588
|
-
* question — is this address banned outright — which is also the only
|
|
589
|
-
* question a caller with no key can be asked.
|
|
590
|
-
*/
|
|
591
|
-
async check(input: { readonly ip: string; readonly apiKeyId?: string | null }): Promise<boolean> {
|
|
592
|
-
return this.store.isBanned(banIdentity(input.ip, input.apiKeyId));
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
/** Records one invalid-model attempt and returns the escalation decision. */
|
|
596
|
-
async noteInvalid(input: {
|
|
597
|
-
readonly ip: string;
|
|
598
|
-
readonly apiKeyId?: string | null;
|
|
599
|
-
}): Promise<ModelAbuseOutcome> {
|
|
600
|
-
return this.store.record({
|
|
601
|
-
ip: input.ip,
|
|
602
|
-
...(input.apiKeyId === undefined ? {} : { apiKeyId: input.apiKeyId }),
|
|
603
|
-
valid: false,
|
|
604
|
-
threshold: this.threshold,
|
|
605
|
-
windowMs: this.windowMs,
|
|
606
|
-
banTtlMs: this.banTtlMs,
|
|
607
|
-
});
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
/** A valid model request does not reset the invalid-attempt window. */
|
|
611
|
-
async noteValid(_input: { readonly ip: string }): Promise<void> {}
|
|
612
|
-
|
|
613
|
-
async listBans(): Promise<readonly ModelAbuseBan[]> {
|
|
614
|
-
return this.store.listBans();
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
async unban(identity: string): Promise<boolean> {
|
|
618
|
-
return this.store.unban(identity);
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/**
|
|
622
|
-
* Operator instant-ban: one caller for one chosen duration, no strike
|
|
623
|
-
* history needed.
|
|
624
|
-
*
|
|
625
|
-
* `apiKeyId: null` is the deliberate blunt instrument — it bans every key
|
|
626
|
-
* used from that address, which is what an operator wants when the address
|
|
627
|
-
* is the problem rather than the credential. `addressOnly` is therefore not
|
|
628
|
-
* a fallback for a missing key; it is a choice the console makes visible.
|
|
629
|
-
*
|
|
630
|
-
* `ttlMs` is clamped to `maxBanTtlMs` here rather than only at the route:
|
|
631
|
-
* the service is the last layer before the store, and a bound that only
|
|
632
|
-
* exists in HTTP is one a caller in-process can walk past. The one value
|
|
633
|
-
* that escapes the clamp is {@link PERMANENT_BAN_TTL_MS} — it *is* the
|
|
634
|
-
* clamp's own ceiling in the other direction, so a request asking for it is
|
|
635
|
-
* asking for "permanent" and gets exactly that, no more.
|
|
636
|
-
*/
|
|
637
|
-
async ban(input: {
|
|
638
|
-
readonly ip: string;
|
|
639
|
-
readonly apiKeyId?: string | null;
|
|
640
|
-
readonly ttlMs?: number;
|
|
641
|
-
readonly addressOnly?: boolean;
|
|
642
|
-
}): Promise<ModelAbuseBan> {
|
|
643
|
-
const identity = input.addressOnly
|
|
644
|
-
? addressBanIdentity(input.ip)
|
|
645
|
-
: banIdentity(input.ip, input.apiKeyId);
|
|
646
|
-
const requested = input.ttlMs ?? this.banTtlMs;
|
|
647
|
-
const ttlMs =
|
|
648
|
-
requested >= PERMANENT_BAN_TTL_MS
|
|
649
|
-
? PERMANENT_BAN_TTL_MS
|
|
650
|
-
: Math.min(Math.max(1, Math.floor(requested)), this.maxBanTtlMs);
|
|
651
|
-
const expiresAt = await this.store.ban(identity, ttlMs);
|
|
652
|
-
const { ip, apiKeyId } = parseBanIdentity(identity);
|
|
653
|
-
return {
|
|
654
|
-
ip,
|
|
655
|
-
apiKeyId,
|
|
656
|
-
reason: "repeated_invalid_model_requests",
|
|
657
|
-
expiresAt,
|
|
658
|
-
permanent: isPermanentBan(expiresAt, Date.now()),
|
|
659
|
-
identity,
|
|
660
|
-
};
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
/**
|
|
665
|
-
* The typed rejection a banned caller receives. 403 with its own code so the
|
|
666
|
-
* console and the client can tell an abuse ban from a per-request rejection.
|
|
667
|
-
* The message names the reason without the ban duration.
|
|
668
|
-
*/
|
|
669
|
-
export function modelAbuseBannedError(): GatewayError {
|
|
670
|
-
// English, because this is a client-facing wire message: the gateway's other
|
|
671
|
-
// public errors are English, and a client that pattern-matches on the text
|
|
672
|
-
// (or an operator reading a foreign-language response) cannot act on one that
|
|
673
|
-
// is not. It also names the scope — the ban is this address *with this key*,
|
|
674
|
-
// not the address outright — so a caller behind a shared address knows a
|
|
675
|
-
// different key from the same address may still work.
|
|
676
|
-
return new GatewayError(
|
|
677
|
-
"model_abuse_banned",
|
|
678
|
-
403,
|
|
679
|
-
"This address is temporarily banned for this API key after too many requests for models this key cannot use. Wait for the ban to lift, then list models to see which ones this key may call.",
|
|
680
|
-
);
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
/**
|
|
684
|
-
* The 404 a rejected model returns, carrying the escalating warning. The
|
|
685
|
-
* message names the strike count without naming how long a ban lasts.
|
|
686
|
-
*/
|
|
687
|
-
export function modelWarningMessage(
|
|
688
|
-
model: string,
|
|
689
|
-
outcome: ModelAbuseOutcome,
|
|
690
|
-
limit: number,
|
|
691
|
-
): string {
|
|
692
|
-
const remaining = Math.max(0, limit - outcome.strikes);
|
|
693
|
-
// "this IP" is now a lie when the ban is scoped to the key: the escalation
|
|
694
|
-
// blocks this address *with this key*, and a caller behind a shared address
|
|
695
|
-
// is entitled to know that switching keys will not help but a different
|
|
696
|
-
// caller on the same address is not affected.
|
|
697
|
-
const tail =
|
|
698
|
-
remaining === 0
|
|
699
|
-
? "The next invalid request will temporarily ban this IP for this API key. Use /v1/models to see allowed models."
|
|
700
|
-
: remaining === 1
|
|
701
|
-
? "1 more invalid request will temporarily ban this IP for this API key. Use /v1/models to see allowed models."
|
|
702
|
-
: `${remaining} more invalid requests will temporarily ban this IP for this API key. Use /v1/models to see allowed models.`;
|
|
703
|
-
return `Model '${model}' is not available to this API key. Warning ${outcome.strikes}/${limit} — ${tail}`;
|
|
704
|
-
}
|