Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
97 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
042e605
Merge main (v3.6.0) into dev
fylorn Oct 11, 2026
ebdb9dc
chore: add a contributor license agreement signed by PR comment (#102)
fylorn Oct 11, 2026
133d610
ci: name the CLA job cla (#103)
fylorn Oct 12, 2026
eef9ff4
Merge main (3.7.0) back into dev
fylorn Oct 12, 2026
30c5f5b
Merge main (3.7.1) back into dev
fylorn Oct 12, 2026
12f4f82
Merge main (3.8.0) back into dev
fylorn Oct 13, 2026
7ef4ff0
Merge main (3.8.1) back into dev
fylorn Oct 13, 2026
72888a4
Bump rustls to 0.23.46 (RUSTSEC-2026-0346 to -0349) (#108)
fylorn Oct 15, 2026
98b6790
Merge main (3.8.2) back into dev
fylorn Oct 16, 2026
d86570c
Tool inspection: decide on settled input, record whole calls (core v0…
fylorn Oct 19, 2026
a9c5150
chore(deps): update node.js to v24.22.0 (#111)
renovate[bot] Oct 19, 2026
57369f6
chore(deps): update pnpm to v12.14.0 (#99)
renovate[bot] Oct 19, 2026
9549fd6
chore(deps): update ghcr.io/zitadel/zitadel docker tag to v4.19.5 (#110)
renovate[bot] Oct 19, 2026
57c05d9
Merge main (3.8.3) back into dev
fylorn Oct 20, 2026
ff8ccf4
feat(web): carry a key's request limits in the Add to ThinkWatch Lite…
fylorn Oct 20, 2026
17b7e9c
Fix the rotation comment: key limits follow the lineage (#116)
fylorn Oct 20, 2026
caa6751
fix(auth): answer database failures in auth middleware with a 503, no…
fylorn Oct 20, 2026
fd13601
Run Argon2 on the blocking pool in request handlers (#118)
fylorn Oct 20, 2026
65aedcd
Merge main (3.9.0) back into dev
fylorn Oct 20, 2026
f9fe732
Route a conversation's encrypted history back to the provider that ho…
fylorn Oct 22, 2026
75b1327
Merge main (3.9.1) back into dev
fylorn Oct 23, 2026
28e537d
Retry upstreams that fail transiently before any content (#122)
fylorn Oct 24, 2026
3d42d54
Models: a reasoning setting; resend an effort the upstream refuses (#…
fylorn Oct 25, 2026
843da63
Reasoning off: lowest accepted level for a refused none; works across…
fylorn Oct 25, 2026
79ab7f7
Release 3.10.0
fylorn Oct 25, 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
117 changes: 116 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,119 @@ target.

## [Unreleased]

## [3.10.0] — 2026-10-25

Upstreams that fail for the moment before any of the answer reached the
client are now tried again instead of the error going straight back, and
a model can set the reasoning effort its requests carry, with the
gateway adapting when an upstream refuses a level. The thinkwatch-core
crates move from v0.81.0 to v0.83.1.

One new setting (`gateway.retry_wait_secs`), one new column
(`models.reasoning_effort`, added on startup) and one new metric; no
route or setting is removed.

### Read before upgrading

- **`models` gains a nullable column, `reasoning_effort`.** The schema
applied at startup adds it (`ADD COLUMN IF NOT EXISTS`); existing
models keep it empty and their requests go out unchanged.
- **A reasoning level an upstream refuses is changed and sent again, for
every model.** This applies whether or not the model has a reasoning
setting and cannot be turned off: when an upstream answers 400 because
of the reasoning level a request carries, the request goes out once
more with a level the upstream accepts, or without one, and the change
is remembered for that provider and model for 7 days. The level sent
can differ from the one the caller asked for, in either direction.
- **A request whose upstreams fail for the moment can take up to two
minutes to fail.** When every route of a model failed with an overload
or a server error, or could not be reached, before any of the answer
reached the client, the gateway now waits and sends the request again
instead of returning the error at once, for up to
`gateway.retry_wait_secs` (120 seconds by default) after the request
arrived. Clients and load balancers with a shorter timeout than that may
give up first. Set `gateway.retry_wait_secs` to 0 to keep the earlier
behaviour.
- **A whole answer that runs into the request timeout is now an error
with a log row.** A non-streaming request's upstream attempt is cut
2 seconds before the gateway's 120-second request timeout (counted from
when the request reached the gateway port), and the client gets the
last upstream error, or a 504 `ProviderTimeout` when there was none,
with its `gateway_logs` row, instead of an empty 408. A retry starts
only when its wait and another attempt as long as the last one fit
before then.
- **A stream's opening events arrive with its first content.** While a
stream can still be sent again, what the upstream sends before any
content (Anthropic `message_start` and `ping`, Responses
`response.created` and `response.in_progress`, a Chat chunk with only
the role) is held back and goes out together with the first content.
While it is held, and while the gateway waits to send again, the client
receives an SSE comment (`: keep-alive`) after every 15 seconds without
data — except Gemini clients, which never receive one. The status is
200 as before.

### Added

- **A model can set its reasoning** (`models.reasoning_effort`, the
Reasoning control in the model editor): `off` takes reasoning out of
requests that have it on, and a level (`low`, `medium`, `high`,
`xhigh`, `max`) changes the effort of requests whose reasoning is on.
Requests without reasoning, such as title generation, go out
unchanged. The setting is written the way each hop's upstream format
writes it: with `off`, OpenAI Chat and Responses upstreams receive
`none`, Gemini 2.5 Flash a thinking budget of 0, and Anthropic and
Bedrock upstreams no thinking; Gemini models that cannot stop thinking
(2.5 Pro, Gemini 3) get no thinking configuration. The response cache
keys on the request together with the setting.
- **A reasoning level the upstream refuses is sent again once with one it
accepts.** On a 400 about the reasoning level, the request goes once
more to the same route with the level the error names (the lowest one
it lists when `none` was refused), or without one when it names none.
This is not counted as a retry, though its time falls within the retry
window. When that is answered, the choice is remembered in Redis per
provider, upstream model and request format for 7 days, so later
requests send what worked straight away; a provider's memory is
cleared when its URL or credentials change, a route relearns its
protocol, or the provider is deleted. A hop whose reasoning level was
changed records `"effort": {asked, sent, reason}` on its
`gateway_logs` row.
- **Transient upstream failures are retried.** When every candidate route
has been tried and the last one failed for the moment — 500, 502, 503,
504 or 529, a connection that could not be made or broke before the
answer's head, a stream that broke off or reported an overload or a
server error (`overloaded_error`, `api_error`, `server_error`,
`server_is_overloaded`, `slow_down`, Gemini `UNAVAILABLE`) before any
content — and none of the answer reached the client, the request waits
2, 4, 8, then 16 seconds (each give or take 20%), or as long as the
upstream said in `retry-after-ms` or `Retry-After` (a zero wait is
ignored, and a shorter one than 250 ms is taken as 250 ms), and goes out again
to the routes that failed that way. A model with one route, and a
stream, goes back to the same route. Each attempt is picked as before:
the strategy, the circuit breaker, the route caps, session affinity
and the provider holding a conversation's encrypted history all apply,
and each failed attempt counts against its route's breaker as it did.
Retries stop when the next wait would end past the deadline; an attempt
already under way is not cut short. Rate limits and spent quota (429),
refused credentials (401, 403), any other 4xx, timeouts, the gateway's
own limits, budgets and policies, and a client that left are never
retried, and neither is a stream after part of its answer went out.
- **`gateway.retry_wait_secs`** (Settings → Gateway → Routing strategy):
how long after a request arrives it may still be sent again, 0 to 600
seconds, default 120; 0 turns retries off.
- **`gateway_provider_retry_total{provider}`**: attempts sent again after
a transient failure, by the provider they went to, next to
`gateway_provider_fallback_total`.
- The `gateway_logs` row of a request that was sent again records
`"retries": n` in its detail, whether it then succeeded or failed.

### Changed

- A provider whose base URL cannot be made into a request fails as
`ProviderError` instead of `NetworkError`, and is not retried.
- An error a stream reports as OpenAI's `server_error` is logged as the
upstream's 500 (`ProviderHttpError`), as the same error answered with
HTTP 500 is, instead of 502 (`ProviderError`).

## [3.9.1] — 2026-10-23

A conversation whose history a provider encrypted, such as a Codex
Expand Down Expand Up @@ -2581,7 +2694,9 @@ 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.9.0...HEAD
[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.10.0...HEAD
[3.10.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.10.0
[3.9.1]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.9.1
[3.9.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.9.0
[3.8.3]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.8.3
[3.8.2]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.8.2
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.9.1"
version = "3.10.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.81.0" }
tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" }
tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" }
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" }
tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.1" }
tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.1" }
tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.1" }
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.1" }

# Web framework
axum = { version = "0.8", features = ["macros", "ws"] }
Expand Down
18 changes: 18 additions & 0 deletions crates/common/src/dynamic_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ macro_rules! dc_getters_u64_from_i64 {
};
}

/// `gateway.retry_wait_secs` when the setting is missing.
pub const RETRY_WAIT_DEFAULT_SECS: i64 = 120;
/// Upper bound on `gateway.retry_wait_secs`, enforced both at write time
/// (the settings handler) and at read time.
pub const RETRY_WAIT_MAX_SECS: i64 = 600;

/// Upper bound on `security.signature_drift_secs`, enforced both at
/// write time (admin settings validation) and at read time (the
/// getter below). Anything beyond five minutes weakens replay
Expand Down Expand Up @@ -440,6 +446,18 @@ impl DynamicConfig {
raw.clamp(0, 86400)
}

/// How long after it arrived a request may still be sent again to
/// upstreams that failed for the moment (overloaded, a server error,
/// a broken connection) before any of the answer reached the client.
/// Clamped to [0, 600]; 0 never sends a request again.
pub async fn retry_wait_secs(&self) -> u64 {
let raw = self
.get_i64("gateway.retry_wait_secs")
.await
.unwrap_or(RETRY_WAIT_DEFAULT_SECS);
raw.clamp(0, RETRY_WAIT_MAX_SECS) as u64
}

/// Exponent for the `latency` strategy: higher = more aggressive
/// (heavier penalty on slow routes). Default 2.0 (aggressive).
/// Clamped to [0.5, 8.0] so a typo can't make weights collapse
Expand Down
4 changes: 4 additions & 0 deletions crates/common/src/models/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ pub struct Model {
/// limit.
#[serde(default)]
pub max_output_tokens: Option<i32>,
/// The reasoning setting: `off`, or a level (`low` … `max`) for
/// requests whose reasoning is on. `None` ⇒ requests go out as sent.
#[serde(default)]
pub reasoning_effort: Option<String>,
}

fn default_enabled() -> bool {
Expand Down
1 change: 1 addition & 0 deletions crates/gateway/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ rust_decimal = { workspace = true }

[dev-dependencies]
http_1x = { package = "http", version = "1" }
tokio = { workspace = true, features = ["test-util"] }
12 changes: 10 additions & 2 deletions crates/gateway/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,17 @@ pub enum GatewayError {
/// Upstream returned a non-2xx, non-429, non-401 status. The
/// status is kept structured so error-classifier metrics stay
/// readable and the gateway can classify retry-eligible 5xx
/// versus poison 4xx without parsing the message.
/// versus poison 4xx without parsing the message. `retry_after` is
/// when the upstream said to try again (`retry-after-ms` or
/// `Retry-After`), which an overloaded upstream can send with a 503:
/// the gateway waits that long before sending to it again (see
/// `proxy::retry`).
#[error("Provider HTTP {status}: {message}")]
ProviderHttpError { status: u16, message: String },
ProviderHttpError {
status: u16,
message: String,
retry_after: Option<std::time::Duration>,
},
/// Upstream took longer than the configured timeout. Distinct
/// from a network drop because the request reached the upstream
/// — only the response was missing in time.
Expand Down
Loading
Loading