Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
a890511
chore: pin the core dependency to a tag instead of tracking main (#25)
fylorn Sep 23, 2026
a90e9f3
feat!: forward what can be forwarded, convert only what must be (#26)
fylorn Sep 23, 2026
ca7647c
refactor: take the circuit-breaker registry and metric labels back fr…
fylorn Sep 23, 2026
a6678cf
refactor: redact PII with core's guard engine, and restore tool argum…
fylorn Sep 23, 2026
437a671
feat: inspect the tool calls an upstream returns (#29)
fylorn Sep 23, 2026
038796b
build: keep only line tables in dev and test builds (#30)
fylorn Sep 24, 2026
05f52e8
feat: one circuit-breaker state machine, and hidden characters in req…
fylorn Sep 24, 2026
8a76395
fix: make the integration suite pass again (#32)
fylorn Sep 24, 2026
6482690
test: fix the two flaky integration tests (#33)
fylorn Sep 24, 2026
9f46116
fix: bill a Chat stream whose caller did not ask for usage (#34)
fylorn Sep 24, 2026
3c35ab8
fix: a request the upstream refuses no longer fails over or trips bre…
fylorn Sep 24, 2026
234fcce
Merge main (v1.1.0) into dev
fylorn Sep 24, 2026
82f8061
docs: cut releases from a release branch, and keep the tag's headings…
fylorn Sep 24, 2026
bd57b5d
ci: run checks on pull requests into dev, including the integration s…
fylorn Sep 24, 2026
c1b2085
fix: bill cached input at cache prices, estimate missing usage, strip…
fylorn Sep 24, 2026
ad5b75b
refactor: take back what only this side used from core (#41)
fylorn Sep 24, 2026
3a617f3
feat(gateway): take official hosts, output fallback and error shapes …
fylorn Sep 24, 2026
ed1c8b5
feat(gateway): accept Gemini clients, and the Responses API over a We…
fylorn Sep 24, 2026
28d4387
test: count the probes that miss before the streaming cache hit (#44)
fylorn Sep 24, 2026
d3f36fc
refactor(server): move the catalog's SQL into repositories (#45)
fylorn Sep 24, 2026
83e9bcc
refactor(server): move the dashboard, limits and log-forwarding handl…
fylorn Sep 24, 2026
9b5b326
refactor(server): move the MCP handlers' SQL into repositories (#50)
fylorn Sep 24, 2026
86beb82
refactor(gateway): run the request guards on core's tw-guard engines …
fylorn Sep 24, 2026
1c80b83
refactor(server): move the identity handlers' SQL into repositories (…
fylorn Sep 24, 2026
c25b44f
fix(mcp): revoking a default connection no longer fails with a 500 (#51)
fylorn Sep 24, 2026
dbce845
test: leave the cancelled stream after it has started, not on a timer…
fylorn Sep 24, 2026
c3d2d57
refactor(server): move the access handlers' SQL into repositories (#48)
fylorn Sep 24, 2026
d92c2d1
fix(settings): read security.totp_required as a boolean, and seed aut…
fylorn Sep 24, 2026
7e95183
feat(gateway): record a client that leaves before its response exists…
fylorn Sep 24, 2026
367df3c
feat(auth): enforce the TOTP requirement (#55)
fylorn Sep 24, 2026
443de8d
feat(gateway): keep the last response on a Responses WebSocket (#56)
fylorn Sep 24, 2026
6890c72
Merge main (v2.0.0) into dev
fylorn Sep 24, 2026
d4f6d5a
docs(release): CI runs the integration suite on the release PR (#58)
fylorn Sep 24, 2026
ef3de87
docs(contributing): point vulnerability reports at the organization's…
fylorn Sep 25, 2026
c4f8b1c
docs(readme): current description of ThinkWatch Lite and ThinkWatch C…
fylorn Sep 25, 2026
f259962
feat(providers): authenticate Bedrock with an API key (#61)
fylorn Sep 28, 2026
78f9ab5
test: hold the early-cancel client once its key is known, not on a ti…
fylorn Sep 28, 2026
1b752f2
feat(providers): list Bedrock's models, and let the route editor take…
fylorn Sep 28, 2026
1f08421
fix(bedrock): refuse keys that will not decrypt, and keep instance-ro…
fylorn Sep 28, 2026
e480bfd
refactor(bedrock): use core's shared tw-bedrock, and core v0.55.0 (#65)
fylorn Sep 29, 2026
e1e7999
docs(readme): rewrite both READMEs to be short and accurate (#66)
fylorn Sep 30, 2026
af9eb81
Merge main (v2.1.0) into dev
fylorn Sep 30, 2026
ed6dd39
docs(readme): 37 MCP templates, what the setup wizard does, body reda…
fylorn Sep 30, 2026
9afcc08
fix(rbac): make the seeded team_manager work at team scope (#69)
fylorn Sep 30, 2026
fa3a5b9
fix(rbac): require gateway use, and count only grants that give it (#70)
fylorn Sep 30, 2026
a1eed6f
Merge main (v2.2.0) into dev
fylorn Sep 30, 2026
da88b09
Merge main (sponsor line) into dev
fylorn Oct 2, 2026
1a399e7
Allow clippy::double_must_use on the async_trait BlobStore (#73)
fylorn Oct 2, 2026
8d8d96f
Unify the request guards with thinkwatch-core's rule model (#72)
fylorn Oct 3, 2026
2baa123
Fix what the pre-release review of the guard unification found (#75)
fylorn Oct 3, 2026
33c22dc
Merge main (v3.0.0) into dev
fylorn Oct 3, 2026
dcc8616
Merge main (README: ThinkWatch Lite for individual developers) into dev
fylorn Oct 4, 2026
6b73344
Merge main (v3.1.0) into dev
fylorn Oct 5, 2026
03c393c
Usage limits: token limits refuse, key limits count on the key, Retry…
fylorn Oct 5, 2026
4075855
fix(redis): connect over TLS for rediss:// URLs (#82)
fylorn Oct 5, 2026
33234ee
Concurrent startup, body retention on restart, and Helm network polic…
fylorn Oct 5, 2026
b7a34e1
Merge main (v3.2.0) into dev
fylorn Oct 5, 2026
2ce6471
chore(deps): update rust crate rand to v0.10.1 [security] (#77)
renovate[bot] Oct 6, 2026
0682864
chore(deps): update rust crate xxhash-rust to v0.8.16 [security] (#78)
renovate[bot] Oct 6, 2026
1fbe7bd
chore(deps): take the Renovate dashboard upgrades (#85)
fylorn Oct 6, 2026
2e23fd9
chore(deps): pnpm 12, and ClickHouse row validation on (#86)
fylorn Oct 6, 2026
977cab0
ci: save Rust caches only on pushes, and let releases read CI's image…
fylorn Oct 8, 2026
a19ed65
Merge main (v3.2.1) into dev
fylorn Oct 8, 2026
ea2a81f
Merge main (v3.3.0) into dev
fylorn Oct 9, 2026
e8ae1f1
Stream endings and in-stream errors logged like answers; core v0.68.0…
fylorn Oct 10, 2026
0e070a9
Merge main (v3.3.1) into dev
fylorn Oct 10, 2026
633ad90
Merge main (v3.4.0) into dev
fylorn Oct 10, 2026
235e38b
fix(limits): enforce route caps and close the places limits leaked (#94)
fylorn Oct 10, 2026
31cbfc8
feat: GET /v1/usage answers for the key or its owner; API key limits …
fylorn Oct 10, 2026
739e745
chore(deps): pin thinkwatch-core v0.70.0
fylorn Oct 10, 2026
90936fe
fix(usage): GET /v1/usage is a 503 when the key's limits can't be loaded
fylorn Oct 10, 2026
6eeedeb
docs(readme): route caps, GET /v1/usage and limits that fail to load
fylorn Oct 10, 2026
dfaf830
chore(release): tag 3.5.0
fylorn Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 157 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,161 @@ target.

## [Unreleased]

## [3.5.0] — 2026-10-11

Limits hold where they used to leak: route caps are enforced, a limits
store that cannot be read follows `security.rate_limit_fail_closed`, a
refused model or tool no longer spends a request, a team-scoped role no
longer limits the gateway it does not open, and cached answers count their
tokens. A client can ask the gateway what room its API key has left
(`GET /v1/usage`), and an API key's own limits are edited in the console.
The legacy monthly token quota is gone, and the thinkwatch-core crates move
from v0.69.0 to v0.70.0.

### Read before upgrading

- **Route caps are enforced.** A route's RPM cap and TPM cap
(`model_routes.rpm_cap` / `tpm_cap`, set in the route editor) were saved
but never applied. They now are, per route over a sliding minute, the TPM
cap in weighted tokens like every other token limit (counted after the
response). A route at either cap is skipped and the model's next route
serves the request; when every route left is at its cap, the request gets
429 with `Retry-After`, labelled `route:requests/1m` or
`route:tokens/1m`. Such a request has passed the caller's own limits and
counts on them, as a request every upstream fails does. Review the caps
already set on your routes: a cap that did nothing until now takes effect
on upgrade. Routes with a cap write new Redis keys,
`ratelimit:{route:<route_id>}:ai_gateway:route:…`.
- **A limits store that cannot be read follows
`security.rate_limit_fail_closed`.** When the database failed while a
request's rate limits and budgets were being loaded, the request ran with
no limits at all; when Redis failed while a budget was being read, the
budget read as unspent. Both now follow the setting, as a Redis failure
in the rate limiter always has, and so do the route caps: off (the
default), the request goes on and a warning with the error is logged
(`gateway_limits_load_fail_open_total`,
`lifecycle_budget_fail_open_total`); on, the request is refused, labelled
`limits_unavailable`, `budget_unavailable` or `rate_limiter_unavailable`
(on the AI gateway, 429 with `Retry-After: 30`). With the setting off, a
Redis error used to fail every AI-gateway request anyway, with 502
`Quota exceeded`, at the legacy quota check (see Changed); such a request
now goes on, as the setting says. Turn the setting on to keep refusing
requests while Redis is down.
- **A role granted at team scope no longer limits gateway requests.** Such
a grant administers that team and has never opened a model or an MCP tool
at the gateways, but its rate limits and budgets were applied to every
gateway request of the user who held it. A role's limits now apply
exactly where its gateway grant does: roles assigned globally, and roles
attached to a team the user is a member of. A user who held a limiting
role only at team scope is no longer held to its limits; assign the role
globally, attach it to the team, or set a user limit if they were meant.
- **Answers from the response cache count their tokens.** A cache hit
counted toward request limits but added no tokens to token limits and
budgets, so repeating a cached request got round them. It now adds the
tokens the cached answer records, weighted like any answer (all of its
input as plain input). The cost a cache hit reports stays 0. Callers that
lean on the cache reach their token limits and budgets sooner.
- **Each API key and each user get day and month usage counters in Redis.**
An AI-gateway request made with a key now also counts on the key's
`usage:{user:<owner id>}:api_key_lineage:<lineage id>:daily:<date>` and
`…:monthly:<month>` and on its owner's
`usage:{user:<owner id>}:user:<owner id>:daily:<date>` and
`…:monthly:<month>`, hashes that expire two periods after their last write.
That is one more Redis script per request once it passes the rate limits,
and one when its tokens are counted, after the call or when it is answered
from the response cache. The counters start empty, so the `usage` that
`GET /v1/usage` reports counts from the upgrade on. No setting, database
schema or Helm value changes.

### Added

- **`GET /v1/usage` tells a client what room its API key has left.** Called
on the gateway port with a gateway API key, as a model request is, it
answers about one of two subjects, named in `scope`:

- `"key"` when the key has limits of its own on the AI gateway: `limits`
lists only the key's limits, each with `used` counted for the key, and
`usage` is the key's own (across its rotations).
- `"user"` when it has none: `limits` lists its owner's effective limits
(roles, including those a team grants, merged most-restrictive, then
the user's overrides), each with `used` counted for everything the
owner does, and `usage` is the owner's total over all of their keys.
With no limits on the owner either, `limits` is empty.

```json
{
"scope": "key",
"usage": {"requests_today": 12, "tokens_today": 48210,
"requests_month": 340, "tokens_month": 1290455,
"cost_usd_month": 3.82},
"limits": [
{"scope": "key", "kind": "tokens", "window": "daily", "window_secs": null,
"limit": 100000, "used": 48210, "resets_at": "2026-10-12T00:00:00Z"},
{"scope": "key", "kind": "requests", "window": "1m", "window_secs": 60,
"limit": 60, "used": 4, "resets_at": null}
],
"expires_at": "2026-12-31T00:00:00Z"
}
```

`usage` holds the requests the rate limits let through and the weighted
tokens limits count, answers from the response cache included, for the
UTC day and month, and the cost this month from the request log (`null`
without ClickHouse). Every limit's `used` is read from the counter that
refuses requests, and the one with the least left comes first. `window`
is a rate limit's sliding window (`1m`, `5m`, `1h`, `5h`, `1d`, `1w`, with
its length in `window_secs`) or a budget's calendar period (`daily`,
`weekly`, `monthly`, with its end in `resets_at`, UTC). Limits on the MCP
gateway are not listed. `expires_at` is the key's expiry or the end of
its rotation grace period, whichever comes first. A key a model request
would refuse gets the same `401` or `403`, and `503` means the key's
limits or their counters could not be read, whichever way
`security.rate_limit_fail_closed` is set. Calling it charges no limit, writes no request log row
and is not a use of the key: `last_used_at` stays as it was, so polling
does not keep an idle key from its inactivity timeout. Whoever holds a key
without limits of its own sees its owner's totals and limits; a key handed
to someone else should carry limits of its own. The console's
Configuration Guide lists the endpoint with the other gateway endpoints.
- **An API key's own limits are edited in the console.** The key's edit
dialog has a Limits tab: rate limits (requests or weighted tokens over
1m, 5m, 1h, 5h, 1d or 1w) and budgets (daily, weekly or monthly weighted
tokens), each with what it has used, added, changed and removed through
the existing limits endpoints. They follow the key across rotations.
Reading needs `rate_limits:read` and changing `rate_limits:write`, in a
scope that covers the key, as before; without write access the tab is
read-only, and without read access the dialog is unchanged.

### Fixed

- **A request the key may not make no longer spends a request limit.** The
rate-limit check, which counts the request, ran before the model check on
the AI gateway and before the tool check on the MCP gateway, so a refused
model or tool used up the caller's `requests` limits. Access is now
checked first (after the budget check, which counts nothing).
- **Route caps are applied**, **limits and budgets that cannot be read
follow the fail-closed setting**, **team-scoped roles no longer limit**
and **cached answers count their tokens**: see Read before upgrading.
- Comments that described a limits cache, a `limits:changed` subscriber and
team-level budget caps, none of which exist, now say what happens: limits
are read from the database on every request, nothing subscribes to
`limits:changed`, and teams carry no limits of their own.

### Changed

- **The legacy monthly token quota is removed.** Every request counted its
tokens into `quota:{<user or key>:<model>}:used:<YYYY-MM>` in Redis
against a limit nothing could set, so it never refused anything. The
gateway no longer reads or writes these keys; existing `quota:*` keys can
be deleted. Monthly limits are budgets. Its metric,
`gateway_quota_overflow_total`, is gone too: the Grafana overview panel
that plotted it shows routes at their cap (`gateway_route_capped_total`)
instead.
- The `security.rate_limit_fail_closed` hint in Settings names everything
the setting now covers.
- thinkwatch-core crates (tw-bedrock, tw-breaker, tw-dialect, tw-guard)
v0.69.0 → v0.70.0. The four crates do not change beyond their version.


## [3.4.0] — 2026-10-11

Conversations with reasoning models behind Chat-format upstreams keep their
Expand Down Expand Up @@ -1768,7 +1923,8 @@ unreleased builds should: stop the gateway, run `db/schema.sql`
against PostgreSQL, restart against this tag. The schema is
idempotent end-to-end, so the apply is safe to repeat.

[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.4.0...HEAD
[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.5.0...HEAD
[3.5.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.5.0
[3.4.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.4.0
[3.3.1]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.3.1
[3.3.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.3.0
Expand Down
28 changes: 14 additions & 14 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ members = [
]

[workspace.package]
version = "3.4.0"
version = "3.5.0"
edition = "2024"
# The MSRV: the newest `rust-version` among the locked dependencies
# (sqlx 0.9 and the aws-smithy crates under aws-sigv4). Without it,
Expand Down Expand Up @@ -70,10 +70,10 @@ opt-level = 3
# never re-exported through a local shim. And the reverse: something only
# this side uses (the at-rest crypto, IMDSv2 credentials, the gateway error)
# lives here, not in core.
tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.69.0" }
tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.69.0" }
tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.69.0" }
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.69.0" }
tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.70.0" }
tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.70.0" }
tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.70.0" }
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.70.0" }

# Web framework
axum = { version = "0.8", features = ["macros", "ws"] }
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,10 @@ The gateway (port `3000`) is the only part that clients need to reach. The conso
**Limits and budgets**
- Every limit and budget is checked before the request, against what earlier requests used; tokens are counted after the response, so one request can cross a token limit or a budget before the next is refused. A refused request counts against nothing, and its `429` says in `Retry-After` when the limit frees.
- A limit set on an API key applies on top of its owner's, on a counter of its own.
- A route's RPM and TPM caps count over a sliding minute. A route at its cap is skipped for the model's next route; when every route is at its cap, the request gets `429`. Such a request has passed the caller's own limits and counts on them.
- `GET /v1/usage` on the gateway port tells a client, with its API key, what it has used today and this month and what room its limits leave.
- Redis can be a single node or a Redis Cluster.
- If Redis is unavailable, limits fail open by default. Setting `security.rate_limit_fail_closed` refuses requests instead.
- If Redis is unavailable, or the database fails while a request's limits are loaded, limits fail open by default. Setting `security.rate_limit_fail_closed` refuses those requests instead.
- Budget alerts fire once per period at 50%, 80%, 95% and 100%.

## Documentation
Expand Down
Loading
Loading