Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 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
8151cee
Merge main (v3.5.0) into dev
fylorn Oct 10, 2026
721b5ee
Merge main (README zh) into dev
fylorn Oct 10, 2026
4f87839
feat(console): Add to ThinkWatch Lite; gateway address for every user…
fylorn Oct 11, 2026
4f0ad96
fix(gateway): model listings name the allowed models a prefix route s…
fylorn Oct 11, 2026
d6c646d
fix(console): Add to ThinkWatch Lite keeps the page; the gateway addr…
fylorn Oct 11, 2026
c4cb8c1
chore(release): tag 3.6.0
fylorn Oct 11, 2026
fdb17ef
CHANGELOG: 3.6.0 is released on 2026-10-12
fylorn Oct 11, 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
102 changes: 101 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,105 @@ target.

## [Unreleased]

## [3.6.0] — 2026-10-12

A key shown once can go straight into ThinkWatch Lite: the console's key
screens offer Add to ThinkWatch Lite, and the Configuration Guide has a tab
for it. Every user, not only administrators, now sees the gateway address
an administrator configured, and the model listings name only the models
the calling key may use.

### Read before upgrading

- **Model listings follow the calling key.** `GET /v1/models` and
`GET /v1beta/models` listed every routed model whatever the key; they
now list only the models a request with that key is let through for
(see Fixed). A client that lists models with a narrowed key sees fewer
of them, and a key whose allow-list and owner's roles have nothing in
common lists none. No setting, schema, Helm value or Redis key changes.
- **`general.public_host` must be a host name or an IP address.** Saving
it used to accept anything without `/` or `://`, such as
`gateway.example.com:8443`, and the Configuration Guide then built a
broken URL from it. Such a value is now refused when saved, and one
already stored counts as not set: the guide uses the host the console is
open at instead. If yours carries a port, move the port to
`general.public_port`.
- **The console's Content-Security-Policy allows `frame-src thinkwatch:`.**
The web image's nginx now sends
`frame-src 'self' thinkwatch:`, which Add to ThinkWatch Lite needs to hand
its link to the app (see Added). A policy of your own in front of the
console needs the same directive, or the button does nothing.

### Added

- **"Add to ThinkWatch Lite" for a key shown once.** The new-key and
rotated-key dialogs have an Add to ThinkWatch Lite button next to Copy;
at the end of first-run setup it is on its own line under the key. It
opens a `thinkwatch://import` link with this gateway and the key, and
ThinkWatch Lite (2026.10.16 or later) opens its New upstream dialog with
them filled in; the upstream is created when the user confirms it there.
The link is built in the browser from the key on screen and the
gateway's public address, and is sent nowhere. It is opened in a hidden
frame, so a browser without Lite installed keeps the page, and the key
on it, instead of showing an error page in its place. The button is
unavailable, with the reason beside it, for a key without the
`ai_gateway` surface, for a user whose roles do not grant
`ai_gateway:use` (a key is always its creator's, and the gateway would
refuse its every request), and for a gateway whose public address Lite
does not accept. Lite takes `https://`, and plain `http://` only on this
machine or a private network: private IPv4 ranges (`10/8`, `172.16/12`,
`192.168/16`, `100.64/10`, `169.254/16`), private IPv6 ranges
(`fc00::/7`, `fe80::/10`), and names with no dot or ending in `.local`,
`.lan`, `.internal`, `.intranet`, `.corp`, `.home.arpa` or `.localhost`.
Elsewhere, set `general.public_protocol` to `https` where the gateway is
served over TLS. Once clicked, a line under it links to the download
page. Phones get Copy only.
- **The Configuration Guide has a ThinkWatch Lite tab**, first among the
AI gateway clients: what Lite does, and a button that opens the new-key
dialog filled in for it (named "ThinkWatch Lite", AI gateway only).
Users without `api_keys:create` are told how to get a key and add it by
hand.
- **`GET /api/gateway/info`** on the console port gives any signed-in user
the configured parts of the gateway's public address:
`{"public_protocol": "https", "public_host": "gateway.example.com", "public_port": 443, "gateway_port": 3000}`.
`public_protocol`, `public_host` and `public_port` are
`general.public_protocol`, `general.public_host` and
`general.public_port`, each `null` when not set (or when the stored value
is not one the setting accepts); `gateway_port` is the port the gateway
listens on. The console fills in what is not set from the address it is
open at, the browser's scheme and host, and uses `gateway_port` when no
public port is set. The server reads no request headers for it.

### Fixed

- **The Configuration Guide shows the configured gateway address to every
user.** It read the address from `GET /api/admin/settings/system`, which
needs `settings:read`: everyone else got 403 and an address built from
the browser's host and port 3000, whatever `general.public_*` said. It
now reads `GET /api/gateway/info`. Until that has loaded, and if it
cannot be read, the examples use the browser's scheme and host with port
3000, as before.
- **`GET /v1/models` and `GET /v1beta/models` list only the models the
calling key may use**: the key's `allowed_models` intersected with what
its owner's roles grant, matched exactly or by prefix, the check a
request for the model meets. They listed every routed model, and a
request for one outside that scope was then refused. A model named in
that allow-list is listed when a route serves it, so a key narrowed to
`gpt-4o` lists `gpt-4o` when the route is a prefix such as the `gpt-`
route of a provider with no routes of its own.
- **Saving `general.public_host` checks it** (see Read before upgrading):
`PATCH /api/admin/settings` answers 400, naming what the setting takes,
for a value with a scheme, a port, a path, a user or anything else
beyond a host name or an IP address. In Settings, the field's save
marker turns to an error that gives this message.

### Changed

- The Public Gateway URL hint in Settings says where an empty protocol or
host comes from (the address the console is open at) and that port 0 is
the gateway's listening port, and names Add to ThinkWatch Lite among
what uses the address.

## [3.5.0] — 2026-10-11

Limits hold where they used to leak: route caps are enforced, a limits
Expand Down Expand Up @@ -1923,7 +2022,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.5.0...HEAD
[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.6.0...HEAD
[3.6.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.6.0
[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
Expand Down
12 changes: 6 additions & 6 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ members = [
]

[workspace.package]
version = "3.5.0"
version = "3.6.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
9 changes: 6 additions & 3 deletions crates/common/src/dynamic_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -699,9 +699,12 @@ fn validate_setting(key: &str, value: &Value) -> anyhow::Result<()> {
if s.len() > 253 {
anyhow::bail!("{key}: host too long");
}
// Reject schemes / paths — host is just a hostname or IP
if s.contains("://") || s.contains('/') {
anyhow::bail!("{key}: must be a hostname only (no scheme or path)");
// A host name or an IP address and nothing else: a value the
// gateway address would pass over as not set is refused here.
if !s.is_empty() && crate::validation::bare_host(s).is_none() {
anyhow::bail!(
"{key}: must be a host name or an IP address only (no scheme, port, path or user)"
);
}
}
"general.public_port" => {
Expand Down
54 changes: 54 additions & 0 deletions crates/common/src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,33 @@ pub fn validate_url(url_str: &str) -> Result<(), AppError> {
Ok(())
}

/// `host` as it goes into a URL — a domain name in lower case, an IPv4
/// address, or an IPv6 address in brackets (given with or without them) —
/// or `None` when it is empty or is anything more than a host: a scheme, a
/// port, a path, a user or anything else that would change the URL's
/// shape.
///
/// The rule for `general.public_host`: saving a value it refuses is an
/// error, and one already stored counts as not set.
pub fn bare_host(host: &str) -> Option<String> {
let host = host.trim();
if host.is_empty() {
return None;
}
let bracketed;
let host = if host.parse::<std::net::Ipv6Addr>().is_ok() {
bracketed = format!("[{host}]");
bracketed.as_str()
} else {
host
};
let parsed = url::Host::parse(host).ok()?.to_string();
parsed
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '[' | ']'))
.then_some(parsed)
}

/// Checks an AWS region code such as `us-east-1` or `us-gov-west-1`.
///
/// Bedrock providers keep a region where other providers keep a URL, and
Expand Down Expand Up @@ -507,6 +534,33 @@ mod tests {
assert!(!is_private_ip(&"100.63.255.255".parse().unwrap()));
}

#[test]
fn bare_host_takes_a_host_name_or_an_ip_address_only() {
assert_eq!(
bare_host("GW.Example.com").as_deref(),
Some("gw.example.com")
);
assert_eq!(bare_host(" 10.0.0.5 ").as_deref(), Some("10.0.0.5"));
assert_eq!(bare_host("gateway").as_deref(), Some("gateway"));
assert_eq!(bare_host("::1").as_deref(), Some("[::1]"));
assert_eq!(bare_host("[fd00::5]").as_deref(), Some("[fd00::5]"));
for bad in [
"",
" ",
"gw.example.com:8443",
"https://gw.example.com",
"gw.example.com/v1",
"user@gw.example.com",
"gw.example.com?x=1",
"gw example",
"gw.example.com#top",
"${GATEWAY_HOST}",
"[::1]:3000",
] {
assert_eq!(bare_host(bad), None, "{bad:?}");
}
}

#[test]
fn is_link_local_ipv4() {
assert!(is_link_local(&"169.254.1.1".parse().unwrap()));
Expand Down
12 changes: 3 additions & 9 deletions crates/gateway/src/lifecycle/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -847,16 +847,10 @@ impl Surface for ChatCompletionSurface {
))
}

/// `None` = unrestricted; otherwise the model must match an entry
/// (exactly or by prefix). An empty list allows nothing — it is what
/// a key narrowed to models its owner's roles do not grant ends up
/// with, and the MCP surface reads `[]` the same way.
/// See [`GatewayRequestIdentity::may_call`]. The MCP surface reads
/// an empty list the same way: nothing is allowed.
fn is_access_allowed(identity: &Self::Identity, candidate: &str) -> bool {
identity.allowed_models.as_ref().is_none_or(|allowed| {
allowed
.iter()
.any(|m| candidate == *m || candidate.starts_with(m))
})
identity.may_call(candidate)
}

fn access_denied_response(candidate: &str) -> Self::Response {
Expand Down
40 changes: 40 additions & 0 deletions crates/gateway/src/proxy/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,29 @@ pub struct GatewayRequestIdentity {
pub key_expires_at: Option<chrono::DateTime<chrono::Utc>>,
}

impl GatewayRequestIdentity {
/// Whether this caller may call `model` (the name after aliases).
/// `allowed_models` is the key's list intersected with its owner's
/// role grants: `None` is unrestricted; otherwise the model must
/// match an entry exactly or start with it. An empty list allows
/// nothing — it is what a key narrowed to models its owner's roles
/// do not grant ends up with.
///
/// The one decision behind both the request-time check
/// (`check_access`) and the model listings: `/v1/models` lists only
/// models a request would be let through for. It cannot list every
/// one of them — a prefix entry allows names nobody enumerates — so it
/// lists the routed names and the allow-list's own entries that pass
/// (see `models::callable_models`).
pub fn may_call(&self, model: &str) -> bool {
self.allowed_models.as_ref().is_none_or(|allowed| {
allowed
.iter()
.any(|m| model == m || model.starts_with(m.as_str()))
})
}
}

/// Thin wrapper kept for call-site readability; delegates to
/// `GatewayError::status_code` so the error-path `gateway_logs` row,
/// the streaming `StreamOutcome::UpstreamError` log row, and
Expand Down Expand Up @@ -467,4 +490,21 @@ mod helper_tests {
};
assert_eq!(err.retry_after_secs(), Some(3600));
}

#[test]
fn a_model_is_callable_when_an_entry_matches_it_exactly_or_as_a_prefix() {
let with = |allowed: Option<&[&str]>| GatewayRequestIdentity {
allowed_models: allowed.map(|a| a.iter().map(|m| m.to_string()).collect()),
..Default::default()
};
assert!(with(None).may_call("anything"));
let narrowed = with(Some(&["gpt-4o", "claude-"]));
assert!(narrowed.may_call("gpt-4o"));
assert!(narrowed.may_call("gpt-4o-mini"));
assert!(narrowed.may_call("claude-sonnet-4-5"));
assert!(!narrowed.may_call("gpt-4"));
assert!(!narrowed.may_call("gemini-2.5-pro"));
// An empty list allows nothing.
assert!(!with(Some(&[])).may_call("gpt-4o"));
}
}
Loading
Loading