Changelog
ThinkWatch release notes
Release notes for each version of ThinkWatch Enterprise, ThinkWatch Lite and ThinkWatch Core, listed from newest to oldest. Subscribe via RSS.
Latest releases
October 2026
-
ThinkWatch Lite v2026.10.18 GitHub ↗
Upgrade notes:
- The bundled core is now 0.72.0, with control-plane protocol 47 as before. A remote server on core 0.71.0 or 0.72.0 keeps working.
- Nothing is cleared; the request history, stored payloads and settings carry over.
Fixed:
- A tool call whose arguments had a URL right after a non-ASCII character, such as Chinese text followed by a full-width colon and a link, no longer ends the answer early. Tool-call inspection, on in observe mode by default, crashed on such text while the answer streamed: Codex reported
stream disconnected before completion, and the request was recorded as interrupted by an error inside the gateway. - When the gateway’s own code fails during a request, the request’s error now says what failed and where, instead of only that the request was interrupted.
Outbound redaction (Replace mode):
- A private key echoed in an answer comes back with real line breaks instead of
\n. - A value inside a tool call’s arguments is put back so the arguments still parse, in streamed and whole answers, replays and WebSocket connections.
- Codex’s freeform tool input, such as
apply_patch, is restored while it streams, and over WebSocket a placeholder cut across two messages is restored. - A stored request shows the placeholder the upstream received, also when the client wrote the value with escapes such as
\u00e9or\/.
-
ThinkWatch Enterprise v3.7.1 GitHub ↗
Tool-call inspection no longer crashes on a URL written right after a non-ASCII character, which cut streamed answers off, and redacted values come back exactly as the caller sent them. The thinkwatch-core crates move from v0.71.0 to v0.72.0.
Fixed#
-
A tool call with a URL right after a non-ASCII character no longer breaks the answer. Tool-call inspection, on in observe mode by default, looks for URLs in the arguments of the tool calls an upstream returns. When a non-ASCII character stood directly before a URL, as in
文档见:https://…, finding the URL’s scheme cut that character in two and the gateway panicked. A streamed answer stopped partway, with no error event and no end of stream, and the request was logged with status 499 andclient_cancelled, as if the client had left; on the Responses WebSocket the whole connection closed. A whole answer was refused with a 500 whose body isService panicked, and logged with status 499 as cancelled before the response. No tool-call event was written for that call. Rust’s panic message (… is not a char boundary …, at tw-guard’stools/net.rs) went to standard error, and for a whole answer the gateway’s log also has aService panickederror. Affected since 3.0.0. -
Redacted values come back exactly as the caller sent them. With outbound redaction in enforce mode, a value is now remembered as it reads rather than as the request’s JSON wrote it, and is escaped for each place it goes back into. A value holding characters that JSON writes with an escape, such as the line breaks of a private key, a quote or a backslash, came back wrong:
- In a streamed answer, put back into text (the answer’s own, or the
input of a freeform tool such as Codex’s
apply_patch), a private key arrived with\nin place of each line break. - In a whole answer, including one served from the cache, such a value inside a tool call’s arguments, which are JSON written into a JSON string, was escaped once instead of twice, so the arguments the client received did not parse, or parsed to a different value.
A placeholder split across two pieces of a freeform tool’s streamed input (
response.custom_tool_call_input.delta) also reached the client as written; it is now put back. - In a streamed answer, put back into text (the answer’s own, or the
input of a freeform tool such as Codex’s
Changed#
- thinkwatch-core crates (tw-bedrock, tw-breaker, tw-dialect, tw-guard) v0.71.0 → v0.72.0. Only tw-guard changes.
-
-
ThinkWatch Core v0.72.0 GitHub ↗
This release fixes a crash that cut off streamed answers whose tool calls had a URL right after a non-ASCII character, records the cause when the gateway’s code fails during a request, and makes redacted values come back exactly as they were in every kind of answer.
Upgrade notes
- The control-plane protocol version stays at 47; ThinkWatch Lite releases that include 0.71.0 connect to 0.72.0 and the other way round.
- The request store’s schema and the configuration are unchanged; nothing is cleared.
- New message code:
gw.internal_panic. - For crates that embed the gateway:
tw_guard::redact::replace::restore_jsonis removed (restorenow escapes each value as deeply as it sits), and the ledger (Ledger::table,replacements,placeholder_of) holds decoded values. New:replace::Form,json_escaped,apply_text,restore_text,stream::Restorer::text,Restorer::fresh_as,Restorer::rebookandsse::FrameRestorer::rebook.
Tool calls with a URL after non-ASCII text no longer break the stream. Tool-call inspection looks for URLs in a tool call’s arguments while they stream, and it is on in observe mode by default. Finding a URL’s scheme cut a multibyte character in two when one stood right before the URL, such as Chinese text followed by a full-width colon and a link, and the gateway’s code panicked: the client’s stream ended with a transport error and the request was recorded as interrupted by an internal error. The scheme is now found byte by byte. Masking credentials in URLs on configuration lines had the same flaw and is fixed too. The fault dates from 0.58.0.
Internal errors say where and why. When the gateway’s code panics during a request, the request’s outcome now carries the panic’s message and its place in the source (
gw.internal_panic), and the panic is logged; before, it only said the request was interrupted by an error inside the gateway. Data that the standard library prints into some panic messages is left out.Redacted values come back exactly. A value found in a request is now remembered as it reads, not as it was written in the request’s JSON, and is put back in the form each place needs:
- A private key echoed in a streamed answer comes back with real line breaks instead of
\n. - A value inside a tool call’s arguments, which are JSON text inside JSON, is escaped for every level it sits in, however deep, in streamed answers, whole answers, replays and WebSocket frames, so the arguments still parse.
- Codex’s freeform tool input (such as
apply_patch) is restored while it streams, including a placeholder cut across two pieces. - Over WebSocket, a placeholder cut across two frames is restored, also when the client sends a new value while the answer streams; what is still held when the upstream disconnects goes through the same renaming, reply plugins and tool-call inspection as the rest of the answer.
- The same value seen in a request’s JSON and in the decoded text a plugin reads now has one placeholder number, and a stored request shows the placeholder upstream received even when the client wrote the value with escapes such as
\u00e9or\/. Bodies with nothing to restore are passed on unchanged, byte for byte.
-
ThinkWatch Lite v2026.10.17 GitHub ↗
Upgrade notes:
- The bundled core is now 0.71.0, with control-plane protocol 47. A remote server must run core 0.71.0; servers on 0.70.x do not connect until they are upgraded.
- Nothing is cleared; the request history, stored payloads and settings carry over.
- Check your custom outbound redaction rules. A rule written before this release was a case-sensitive regular expression; it now looks for its text as written and ignores case. A rule that relies on a regular expression keeps loading and simply stops matching, with nothing reported, so what it used to replace is sent as it is. Open each such rule and choose Regex under Match by, and Match case to keep telling capital letters from small ones.
Outbound redaction:
- Field names in a request are no longer searched, and custom rules leave alone the identifiers a protocol needs exactly as they are: item and call ids, types, roles, tool names and model names. A custom rule whose text appeared inside an id that Codex sends with its history used to replace part of the id; the upstream then rejected that request and every later turn of the conversation. Built-in rules still search those values.
- The private-key rule now looks for the end of a key block only within the same text. A key header without an end in one message no longer joins up with an end line in a later message, which used to replace everything in between with one placeholder.
- A custom redaction rule has Match by: Contains looks for the text exactly as written, so
.and*have no special meaning; Regex takes a regular expression. Match case next to it tells capital letters from small ones; it is off by default. The test box tries the chosen settings, and the rule list and the security log detail say how each rule matches.
-
ThinkWatch Enterprise v3.7.0 GitHub ↗
Outbound redaction keeps away from the structure of a request: it no longer looks inside field names, and custom rules leave alone the identifiers a protocol needs as they are, so a rule can no longer break the ids a client sends back with its history. A custom redaction rule now says whether it looks for its text as written or for a regular expression, and whether case matters. The thinkwatch-core crates move from v0.70.0 to v0.71.0.
Read before upgrading#
- Custom redaction rules saved before 3.7.0 now match their text as
written, ignoring case. A custom rule in
security.redacttakes two new optional keys,match(containsorregex) andcase_sensitive. A rule withoutmatchis looked for as plain text, in which.,*,\dand the like have no special meaning; a rule withoutcase_sensitiveignores case. Until now every custom redaction rule was a case-sensitive regular expression, and no rule saved by 3.0 to 3.6 carries either key, including the rules an earlier upgrade converted from the oldsecurity.pii_redactor_patternssetting. A rule written as a regular expression still loads and nothing reports it, but it stops matching: what it used to replace is sent upstream as it is and, withaudit.body_redact_piion, stored unmasked. Nothing is converted automatically. Once every replica runs 3.7.0, open each custom redaction rule on the console’s security page, set Match by to Regex for one written as a regular expression, and tick Case-sensitive where case mattered. A policy written by a script or throughPATCH /api/admin/settingsneeds"match": "regex"and"case_sensitive": trueon each such rule. Settings still in the format from before 3.0.0, converted on the first start of 3.7.0, become case-sensitive regex rules and keep matching as before. - Upgrade every replica before editing redaction rules, and do not roll
back afterwards. 3.6 refuses a redaction policy with keys it does not
know. A 3.6 instance that reads a policy in which any custom rule has
matchorcase_sensitivelogsUnreadable guard policyand runs redaction on the factory policy: observe mode, the built-in rules as shipped, no custom rules, so nothing is replaced on the wire. Any change to outbound redaction saved from the 3.7.0 console, even a mode switch, writesmatchon every custom rule. During a rolling upgrade, edit redaction rules only once no 3.6 instance is left; to roll back to 3.6 after editing them, first removematchandcase_sensitivefrom every custom rule insecurity.redact. - Redaction no longer searches field names, and custom rules skip
protocol identifiers. No rule looks inside a JSON key any more. Custom
rules also leave alone the values of
id,call_id,item_id,tool_use_id,tool_call_id,toolUseId,previous_response_id,response_id,type,role,name,namespace,model,status,objectandphase. Built-in rules still search those values, and every other value is searched by every rule as before, including fields such asuser_idoraws_access_key_idinside tool arguments. A custom rule meant to catch a value under one of those keys, such as a tool name or a model name, no longer does. The same holds for masking stored bodies (audit.body_redact_pii). - The private-key rule replaces less. The header of a key block and its end line are now looked for within the same text (see Fixed). A header in one message and an end line in another are no longer replaced, along with everything between them, as one key.
Added#
- Match by and Case-sensitive for custom redaction rules. The custom
rule dialog for outbound redaction offers Contains and Regex, as the
content filter’s does, and a Case-sensitive checkbox. Contains looks for
the text exactly as written; leading and trailing spaces count. The rule
list says how each rule matches, such as “Contains
x, ignoring case” or “Regexx, case-sensitive”. Trying a sample in the dialog uses the same settings:POST /api/admin/security/redact/testtakesmatchandcase_sensitive. Code points remain a content filter matcher and are refused for redaction.
Fixed#
- Custom redaction rules no longer break the ids a client sends back.
Rules ran over the whole request body, field names and identifiers
included, so a custom rule could match inside an id: Codex sends ids such
as
fco_followed by a hexadecimal UUID, and a rule whose text can be spelled in hexadecimal turned one intofco_…<<TW_SECRET_1>>…. The upstream rejected the request, and since the history is sent again on every turn, every later turn of that conversation as well. See Read before upgrading for what is no longer searched. - Private keys stay within one text. The private-key rule looked for the end of a key block anywhere after its header. A header without an end in one message, such as a file read halfway or code that spells the marker, joined up with an end line in a later message, and everything in between, earlier turns included, was replaced by a single placeholder.
Changed#
- thinkwatch-core crates (tw-bedrock, tw-breaker, tw-dialect, tw-guard) v0.70.0 → v0.71.0.
- The Licensor in
LICENSEis now the project’s owner, named in the file, instead of “ThinkWatch Contributors”; the Business Source License 1.1 terms are unchanged. - A first pull request to this repository asks its author to sign the
Contributor License Agreement (
CLA.md) once, by a comment on the pull request;CONTRIBUTING.mdexplains how.
- Custom redaction rules saved before 3.7.0 now match their text as
written, ignoring case. A custom rule in
-
ThinkWatch Core v0.71.0 GitHub ↗
This release keeps outbound redaction away from the structure of a request, so a rule can no longer break the ids a client sends back, and lets a custom redaction rule look for its text as written or by a regular expression, with or without telling capital letters from small ones.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) goes from 46 to 47. ThinkWatch Lite connects only to a core with the same protocol version: ThinkWatch Lite 2026.10.16 includes 0.70.1 (protocol 46) and does not connect to 0.71.0. A server used with it stays on 0.70.1 until the app is updated to a release that includes 0.71.0;sudo twcore upgrade --version 0.70.1 --restartswitches a server back. - The request store’s schema is unchanged; nothing is cleared.
- Configuration: a custom redaction rule takes two new optional keys,
match(containsorregex) andcase_sensitive. A custom redaction rule withoutmatchis now looked for as written and ignores case; before, every custom redaction rule was a case-sensitive regular expression. Such a rule still loads and is not reported anywhere; it simply stops matching, and what it used to replace is sent as it is. Check every custom redaction rule before upgrading: one written as a regular expression needsmatch: regex, andcase_sensitive: trueto keep telling cases apart. Existing configurations load unchanged; a configuration that usesmatchorcase_sensitiveon a redaction rule does not load in 0.70.x. - New message codes:
config.rule_match_bad,security.redact_match_bad. - Changed in the protocol:
CustomRuleSaveandSecurityTestRequest:matchapplies to redaction rules too (containsorregex;containswhen not given); newcase_sensitive(off when not given).Matcher:containsandregexgaincase_sensitive. A custom redaction rule is shown ascontainsorregex; a tool-call rule reportstrueunless its pattern starts with(?i).RuleSnapshotgainscase_sensitive; a custom redaction rule’s snapshot now carriesmatching.
Redaction leaves the request’s structure alone. Rules were run over the whole request body, field names and identifiers included, so a custom rule matched inside the ids a client sends with its history: Codex sends ids such as
fco_followed by a hexadecimal UUID, and a rule whose text could be spelled in hexadecimal turned one intofco_…<<TW_SECRET_1>>…. The upstream rejected the request because the id contained characters an id cannot have, and since the history is sent again on every turn, every later turn of that conversation was rejected too. No rule looks inside field names any more. Custom rules also leave alone the values a protocol needs exactly as they are:id,call_id,item_id,tool_use_id,tool_call_id,toolUseId,previous_response_id,response_id,type,role,name,namespace,model,status,objectandphase. Built-in rules still search those values, since a key or a number cannot be mistaken for a hexadecimal id, and every other value is searched by every rule as before, including fields such asuser_idoraws_access_key_idinside tool arguments.Private keys stay within one text. The private-key rule looked for the end of a key block anywhere after its first line. A header without an end in one message, such as a file read halfway or code that spells the marker, joined up with an end line in a later message, and everything in between, earlier turns included, was replaced by a single placeholder. The header and the end are now looked for within the same text.
Match by. A custom redaction rule now says how it matches, as content rules already do:
containslooks for the text exactly as written, so.,*and the like have no special meaning, andregextakes a regular expression. A rule matches regardless of case unless it setscase_sensitive: true. Code points remain a content-filter matcher only and are refused for redaction. The security log records how a custom redaction rule matched and whether it told cases apart when it found something. - The control-plane protocol version (
-
ThinkWatch Enterprise v3.6.0 GitHub ↗
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/modelsandGET /v1beta/modelslisted 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_hostmust be a host name or an IP address. Saving it used to accept anything without/or://, such asgateway.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 togeneral.public_port.- The console’s Content-Security-Policy allows
frame-src thinkwatch:. The web image’s nginx now sendsframe-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://importlink 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 theai_gatewaysurface, for a user whose roles do not grantai_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 takeshttps://, and plainhttp://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.arpaor.localhost. Elsewhere, setgeneral.public_protocoltohttpswhere 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:createare told how to get a key and add it by hand. GET /api/gateway/infoon 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_hostandpublic_portaregeneral.public_protocol,general.public_hostandgeneral.public_port, eachnullwhen not set (or when the stored value is not one the setting accepts);gateway_portis 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 usesgateway_portwhen 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 needssettings:read: everyone else got 403 and an address built from the browser’s host and port 3000, whatevergeneral.public_*said. It now readsGET /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/modelsandGET /v1beta/modelslist only the models the calling key may use: the key’sallowed_modelsintersected 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 togpt-4olistsgpt-4owhen the route is a prefix such as thegpt-route of a provider with no routes of its own.- Saving
general.public_hostchecks it (see Read before upgrading):PATCH /api/admin/settingsanswers 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.
- Model listings follow the calling key.
-
ThinkWatch Lite v2026.10.16 GitHub ↗
Upgrade notes:
- The bundled core is now 0.70.1, with control-plane protocol 46 as before. A remote server on core 0.70.0 or 0.70.1 keeps working.
- Nothing is cleared; the request history, stored payloads and settings carry over.
Import links:
- An import link now opens the New upstream dialog with its settings filled in, on the connection or account step, instead of a separate confirmation dialog. Every setting can be reviewed and changed before Create.
- A link can set what the dialog sets for every service type: the service, the authentication method, the address, protocol and key, OAuth and AWS access keys, request headers, client identity forwarding, the concurrency limit, the balance source, models added by hand, the enabled models, model specs, the billing mode and prices. A link for a ChatGPT or Z.ai / BigModel account carries no credentials and opens on the sign-in step.
- A link cannot set the outbound proxy, an AWS profile, environment variables, aliases, routes or the default upstream. Authentication headers are refused.
- Every step names the host that will receive requests and the key. Nothing is written and nothing is sent until Next is chosen on the connection step or a sign-in is started.
- A link with a parameter this version does not know shows Cannot import this link, with Check for updates. Links made for earlier versions work as before.
- Plain
http://addresses on a private network, such as a company’s ThinkWatch Enterprise gateway, are accepted; the dialog notes that they are not encrypted. ThinkWatch Enterprise 3.6.0 adds Add to ThinkWatch Lite next to a new key.
Upstreams:
- A custom upstream has Balance source under Advanced settings.
- Steps with many settings scroll instead of being squeezed: when editing, Check connection is back in the connection section with its full result; advanced settings, OAuth and AWS fields and the ChatGPT account page are laid out in their natural order again, and the hints under the base URL, headers, session token, access token and outbound connection are back.
Plugins:
- With every plugin disabled, the plugin list again shows each plugin’s name, description, permissions and settings after an upgrade, not only its file name.
-
ThinkWatch Core v0.70.1 GitHub ↗
This release lists disabled plugins in full again after a core upgrade.
Upgrade notes
- The control-plane protocol version stays at 46; ThinkWatch Lite releases that include 0.70.0 connect to 0.70.1 and the other way round.
- The request store’s schema and the configuration are unchanged; nothing is cleared.
- No new message codes.
Disabled plugins. When no plugin is enabled, core does not start the plugin runtime, and the plugin list took each plugin’s name, description, permissions, request kinds, scope and settings from a cache that every core upgrade invalidates. With every plugin disabled, which is how the bundled default plugins ship, nothing rebuilt it, so after an upgrade the list showed each plugin by its id only. Core now reads these from the plugin’s approved file without starting the runtime when the cache has nothing; only the hooks a plugin exports are unknown until it runs. What is read this way is used for display only: enabling a plugin, trying it, saving its code and approving a changed file still compile it first.
-
ThinkWatch Lite v2026.10.15 GitHub ↗
Upgrade notes:
- The bundled core stays at 0.70.0, with control-plane protocol 46. A remote server on core 0.70.0 keeps working.
- Nothing is cleared; the request history, stored payloads and settings carry over.
New upstream:
- Every step of the dialog fits the default window without scrolling; only long lists such as the model table scroll in their own box.
- The first step lists services in four columns, with the search box beside the steps.
- ThinkWatch, Sub2API and New API / One API take an API key only; OAuth remains for custom services. An existing upstream that already uses OAuth keeps it.
- Request headers, client identity forwarding, the behaviour when the proxy is unavailable and the concurrency limit are under Advanced settings, collapsed unless one of them is set.
- Next checks the connection. When it passes, the Models step opens with the result on top: latency, the number of models found and the balance where the service reports one. When it fails, the step stays open with the reason, Retry and Continue anyway.
- Signing in with a ChatGPT account offers two methods side by side: the browser, where the sign-in link can also be copied, and a device code. For Z.ai / BigModel, the method and the site share one row, and BigModel (mainland China) is listed first and selected by default.
- After signing in, the dialog goes straight to Models, with the account on top; a ChatGPT account also shows its tightest quota window. Back on the account step, the signed-in account has Sign in again.
Editing an upstream:
- The dialog uses the same layout and rules. Check connection sits at the left of the footer, with its result beside it.
-
ThinkWatch Lite v2026.10.14 GitHub ↗
Upgrade notes:
- The bundled core is now 0.70.0, with control-plane protocol 46. A remote server must be upgraded to core 0.70.0 together with the app: this version does not connect to a server on 0.69.0, and 2026.10.13 and earlier do not connect to 0.70.0.
- Nothing is cleared; the request history, stored payloads and settings carry over.
New upstream:
- The first step lists services in three groups: model providers (Anthropic, OpenAI, Google Gemini, DeepSeek, Z.ai / BigModel), platforms and relays (Amazon Bedrock, OpenRouter, ThinkWatch, Sub2API, New API / One API), and local and custom (Ollama, Custom), each with its logo and a one-line description, and a search box. OpenRouter, ThinkWatch, Sub2API and New API are new.
- The second step asks only for what the chosen service needs. The authentication methods are those the service has: OpenAI offers an API key or a ChatGPT account, Z.ai / BigModel an API key or an account sign-in, Bedrock its three methods, custom and relay services an API key or OAuth; services with a single method show no choice. Fields the service already decides — its address, its protocol, a key for Ollama — are filled in or left out.
- Signing in with a ChatGPT or Z.ai / BigModel account happens inside the dialog, with the same notices as before. The sign-in link can be copied, to open it in a browser other than the default one; ChatGPT can also sign in with a device code. When the app is connected to a remote core, ChatGPT signs in with a device code only, with the verification address and the code each ready to copy. After signing in, the next steps set the models and billing of the upstream just created.
Editing an upstream:
- The dialog recognizes the service from the upstream’s address, or for Sub2API, New API and ThinkWatch from where its balance is read, shows its logo and name, and applies the same rules as creating it. A value already set is always shown, even where the service would not normally ask for it.
- A Z.ai or BigModel upstream written by signing in shows Account sign-in as its method, with Sign in again; one with a typed key can switch to signing in.
Balances:
- The Quota / billing column shows each upstream’s balance where its service reports one: the key’s quota as a bar with what is left, the tightest of its time windows, money left in the account, or what has been spent. Hovering lists every window and the exact amounts. Sources are OpenRouter, DeepSeek, Moonshot, Sub2API, New API / One API and ThinkWatch Enterprise; the source is recognized from the address or set by the service picked when the upstream was created.
- On a ThinkWatch Enterprise gateway, a key with limits of its own shows its own limits and use; a key without shows the account’s limits and its total over all of its keys, marked as the account’s.
- Check connection shows the balance under its result, and an upstream’s menu has Refresh balance. A balance that cannot be read shows why on hover; reading it never affects requests.
-
ThinkWatch Enterprise v3.5.0 GitHub ↗
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 withRetry-After, labelledroute:requests/1morroute: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, labelledlimits_unavailable,budget_unavailableorrate_limiter_unavailable(on the AI gateway, 429 withRetry-After: 30). With the setting off, a Redis error used to fail every AI-gateway request anyway, with 502Quota 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’susage:{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 theusagethatGET /v1/usagereports counts from the upgrade on. No setting, database schema or Helm value changes.
Added#
-
GET /v1/usagetells 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 inscope:"key"when the key has limits of its own on the AI gateway:limitslists only the key’s limits, each withusedcounted for the key, andusageis the key’s own (across its rotations)."user"when it has none:limitslists its owner’s effective limits (roles, including those a team grants, merged most-restrictive, then the user’s overrides), each withusedcounted for everything the owner does, andusageis the owner’s total over all of their keys. With no limits on the owner either,limitsis empty.
{ "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" }usageholds 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 (nullwithout ClickHouse). Every limit’susedis read from the counter that refuses requests, and the one with the least left comes first.windowis a rate limit’s sliding window (1m,5m,1h,5h,1d,1w, with its length inwindow_secs) or a budget’s calendar period (daily,weekly,monthly, with its end inresets_at, UTC). Limits on the MCP gateway are not listed.expires_atis the key’s expiry or the end of its rotation grace period, whichever comes first. A key a model request would refuse gets the same401or403, and503means the key’s limits or their counters could not be read, whichever waysecurity.rate_limit_fail_closedis set. Calling it charges no limit, writes no request log row and is not a use of the key:last_used_atstays 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:readand changingrate_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
requestslimits. 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:changedsubscriber and team-level budget caps, none of which exist, now say what happens: limits are read from the database on every request, nothing subscribes tolimits: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; existingquota:*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_closedhint 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.
- Route caps are enforced. A route’s RPM cap and TPM cap
(
-
ThinkWatch Core v0.70.0 GitHub ↗
This release reads each upstream’s balance: the money left on a key, the quota of a relay key and its time windows, a key’s limit and spend on OpenRouter, or the usage and limits a ThinkWatch Enterprise gateway reports for a key. It also records which upstreams were written by signing in to a Z.ai or BigModel account.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) goes from 45 to 46. ThinkWatch Lite connects only to a core with the same protocol version: ThinkWatch Lite 2026.10.13 includes 0.69.0 (protocol 45) and does not connect to 0.70.0. A server used with it stays on 0.69.0 until the app is updated to a release that includes 0.70.0;sudo twcore upgrade --version 0.69.0 --restartswitches a server back. - The request store’s schema is unchanged; nothing is cleared.
- Configuration: an upstream takes two new optional keys,
balance:andsigned_in:. Existing configurations load unchanged and read balances where the address is recognized. - New message codes:
control.balance_none,gw.balance.rejected,gw.balance.status,gw.balance.unrecognized,gw.balance.timeout,gw.balance.connect,gw.balance.request_failed. - Changed in the protocol:
ProviderViewgainsbalance(the latest reading),balance_setting(what the configuration says) andsigned_in;ProviderInputgainsbalance;ProviderTestResultgainsbalance.- New types
Balance,BalanceQuota,BalanceWindow,BalanceScope,Spent,SpentPeriod,BalanceSetting. - New event
balance_updatedand endpointPOST /providers/{name}/balance/refresh:404 control.balance_nonefor an upstream with no balance to read,502with the reason when the source could not be determined because a request failed.
Balances. An upstream’s
balance:says where its balance is read from, with the upstream’s own key and proxy:openrouter,deepseek,moonshot,sub2api,newapi(New API and One API) orthinkwatch(a ThinkWatch Enterprise gateway);offreads nothing. The default,auto, recognizes OpenRouter, DeepSeek and Moonshot by their address; any other address, except Anthropic’s, OpenAI’s and Gemini’s own, is asked once whether it is a Sub2API relay, a New API relay or a ThinkWatch Enterprise gateway, and the answer is kept in memory only. ChatGPT and Z.ai accounts and Bedrock upstreams have no balance; their quota windows are reported as before.A reading is one or more of: money left on the key (a wallet, in its own currency), a total quota with what has been used of it, time windows with their limits, use and reset time, and the money already spent. Per source:
- OpenRouter: the key’s own limit, if it has one, and what the key spent this month (or in total), with the key’s expiry. The account’s credits need a management key and are not read.
- DeepSeek and Moonshot: the account balance, in its currency.
- Sub2API: the key’s quota and its 5-hour, daily and weekly windows, or its subscription’s windows, or the wallet balance; what was spent today is the amount actually charged.
- New API and One API: the key’s quota and use, in the unit the site displays (US dollars, yuan or tokens, read from the site’s public status); a site that does not say is marked as an unknown unit rather than dollars.
- ThinkWatch Enterprise: the key’s limits and usage when the key has limits of its own, otherwise the account’s limits and its total over all of its keys, each limit saying which.
Balances are read at start, at most once a minute after requests through that upstream end, every ten minutes otherwise, and on request; Check connection also reads the balance and runs the recognition for
auto, waiting at most five seconds for it. A failed read keeps the previous reading and says why; reading a balance never affects forwarding. Requests go only to the upstream’s own address, through its proxy, and redirects are not followed.Z.ai and BigModel sign-in. An upstream written by signing in to a Z.ai or BigModel account now carries
signed_in: zaiorsigned_in: bigmodel, andProviderView.signed_insays so, so an app can show it as an account sign-in. Signing in again sets it on upstreams written before this release. Saving the upstream with the same key keeps it; replacing or removing the key clears it. - The control-plane protocol version (
-
ThinkWatch Enterprise v3.4.0 GitHub ↗
Conversations with reasoning models behind Chat-format upstreams keep their reasoning from turn to turn, Gemini’s tool loops keep their thought signatures through a conversion, and a tool call that a Chat-format model writes into its answer text reaches the caller as a tool call. The thinkwatch-core crates move from v0.68.0 to v0.69.0.
Read before upgrading#
- Converted answers from Chat-format upstreams can carry tool calls that
arrived as text before. Self-hosted and relayed Chat-format models
(DeepSeek, GLM, Qwen, Llama and others) sometimes write a tool call into
contentin the template they were trained on instead of answering withtool_calls. On a route that converts the request to the Chat format, such a call to a tool the request defined now reaches the caller as a structured tool call, which the caller executes, where it used to be a tagged piece of prose the caller showed and did nothing with. Tool-call inspection sees these calls like any other and refuses or cuts them under the same rules; the request log’s response body records them as tool calls, and a recovered call ends the answer with the tool-use stop reason instead ofstop. A request that defined no tools, a name the request did not define, and a bare JSON object in prose stay text. A Chat caller on a Chat-format route is forwarded as sent and unchanged. No setting, schema, API route, Helm value or Redis key changes.
Fixed#
- Gemini thought signatures are not counted as answer text. When a
request’s usage has to be estimated (
usage_estimated), thethoughtSignatureon a Gemini thought or function call — an opaque blob of up to several kilobytes — was counted as output text; it is left out now, as the other formats’ reasoning signatures were.
Changed#
- thinkwatch-core crates (tw-bedrock, tw-breaker, tw-dialect, tw-guard)
v0.68.0 → v0.69.0. Only tw-dialect changes:
- Reasoning from a Chat-format upstream round-trips (tw-dialect).
Reasoning a Chat-format upstream returned (
reasoning_content, orreasoningas OpenRouter and vLLM call it) was dropped from the next turn’s request on every route, so a Claude Code or Codex conversation with tools against DeepSeek, which requires the reasoning of a tool-calling turn on the following requests of that turn, lost it every turn. Such reasoning is now carried in the caller’s own format with atw1.ch.signature, read back on the next turn, and written asreasoning_contentonly to a Chat-format upstream; to an upstream of another format it is left out as before, and reasoning another vendor signed is still not sent to a Chat-format upstream. Consecutive assistant messages are merged before a request is written in the Chat format, so a Responses caller’s reasoning, text and tool call of one turn arrive as one message. The reasoning history a request carries back counts as its input, so the input tokens of those turns grow by it, as the upstream charges them. - Gemini thought signatures on function calls are kept (tw-dialect).
Gemini with thinking puts a turn’s thought signature on the first
functionCallpart, and the conversion only read signatures from thought parts, so a converted Gemini tool loop lost the signature and with it the model’s reasoning continuity. The signature on a function call is now carried through a conversion and written back on thefunctionCallpart; a call with no real signature gets theskip_thought_signature_validatorplaceholder as before. A Gemini request forwarded as sent that carries a signature from another route on afunctionCallpart keeps the part with the placeholder, where the part was removed and the tool loop broke. - Tool calls written into a Chat-format upstream’s answer text are recognised (tw-dialect): see Read before upgrading. In this edition the answer assembled for the request log and the cache of a Chat caller’s stream forwarded as sent to a Chat-format upstream keeps such a call as the text the caller received, rather than the structured call the conversion would have made of it.
- Reasoning from a Chat-format upstream round-trips (tw-dialect).
Reasoning a Chat-format upstream returned (
- Converted answers from Chat-format upstreams can carry tool calls that
arrived as text before. Self-hosted and relayed Chat-format models
(DeepSeek, GLM, Qwen, Llama and others) sometimes write a tool call into
-
ThinkWatch Lite v2026.10.13 GitHub ↗
Upgrade notes:
- The bundled core is now 0.69.0, with control-plane protocol 45. A remote server must be upgraded to core 0.69.0 together with the app: this version does not connect to a server on 0.68.0, and 2026.10.12 and earlier do not connect to 0.69.0.
- Nothing is cleared; the request history, stored payloads and settings carry over.
Upstreams:
- New upstream starts with a Service step: a searchable grid in two groups, sign-in accounts (ChatGPT, Z.ai / BigModel) first, then API access (Anthropic, OpenAI, Google Gemini, Amazon Bedrock, DeepSeek, Ollama and Custom). Picking one goes straight to Connection, where the name, address and protocol are already filled in; the dialog’s title shows the choice, and Back returns to the grid with it marked.
Routing:
- Rules can match the time of day: a weekday picker and a start and end time make one window, a rule can hold several, and a window may run overnight. The gateway evaluates it in the local time of the machine the core runs on; the dry run names the time it saw when the condition did not match.
- On the routing map, each request in flight moves along its whole path in a colour of its own, so two requests on the same edge stay apart.
Sessions:
- Every turn of a session shows how full the model’s context window was: the input tokens over the window, with cache reads as their own segment. Opening a turn shows an estimated breakdown into the system prompt, the tool definitions, the earlier history and the last user message; when the request body is no longer stored, the turn says so.
Warnings:
- Every amber warning can be ignored: each line of the overview’s Needs attention strip, the notice that request recording could not start, the lost-connection banners and the config-rejected banner. An ignored item returns when it has news (a newer failure, one more upstream down, a different config error); Show again in Settings › General brings them all back, together with the guidance hints.
Menu bar:
- The menu bar item no longer turns orange or red for a tight or exhausted quota; the quota windows inside the menu keep their colour, and the hover text still names the tightest window.
Gateway (core 0.69.0):
- A model’s reasoning survives format conversion: a Chat-format upstream’s
reasoning_content(DeepSeek, Kimi, GLM) is sent back to that upstream on the next turn, and a Gemini model’s thought signature on a function call is kept through Anthropic and Responses clients. - Tool calls that a Chat-format model writes into its answer text (
<tool_call>blocks, DeepSeek’s and GLM’s own forms) are recognised and delivered as ordinary tool calls when the request defined tools; they go through tool-call inspection like any other. - Request bodies sent compressed (gzip, deflate, zstd) are read on every route, and compressed upstream answers reach the client decoded.
- A streamed request waiting for its key’s concurrency slot or rate window receives keepalives after 15 seconds, as one waiting on an upstream already did, so clients no longer time out while queued.
Removed:
- Notices about things the gateway handles by itself: the account box no longer says when a credential expires and renews, and a refresh token written back successfully no longer raises a banner. Only a failed write-back is reported.
-
ThinkWatch Core v0.69.0 GitHub ↗
This release keeps a model’s reasoning intact across API formats, recognises tool calls that a Chat-format model writes into its answer text, reads compressed request bodies and compressed upstream answers, keeps a queued request’s connection alive, lets routing rules match the time of day, and tells, for every turn of a session, how full the model’s context window was and what filled it.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) goes from 44 to 45. ThinkWatch Lite connects only to a core with the same protocol version: ThinkWatch Lite 2026.10.12 includes 0.68.0 (protocol 44) and does not connect to 0.69.0. A server used with it stays on 0.68.0 until the app is updated to a release that includes 0.69.0;sudo twcore upgrade --version 0.68.0 --restartswitches a server back. - The request store’s schema is unchanged; nothing is cleared.
- Configuration: a routing rule’s
whentakes a new optionaltimecondition (see below). Existing configurations load unchanged. - New message codes:
gw.request.unsupported_encoding,gw.request.bad_encoding,gw.request.decoded_body_over_limit,engine.rule_time_syntax,engine.time_syntax. - Changed in the protocol:
ConditionFieldgainstime.TurnViewgainscontext_window.- New endpoint
GET /sessions/{session_id}/turns/{turn_id}/context(→TurnContext).
Reasoning across API formats. When the gateway converts between formats, a model’s reasoning now survives the round trip in the cases that used to lose it:
- A Chat-format upstream’s
reasoning_content(DeepSeek, Kimi, GLM and others) is carried to the client in its own format and sent back to that upstream on the next turn, so a conversation with tool calls keeps the reasoning the model requires. Reasoning from a Chat upstream is never sent to an upstream of another vendor, andreasoning_contentis written only for reasoning that came from a Chat upstream, since OpenAI’s own API refuses unknown fields. Consecutive assistant items from a Responses client (reasoning, text, tool calls) are folded into one Chat message. - A Gemini model’s
thoughtSignatureon afunctionCallpart is read from upstream answers and from client history, carried through Anthropic and Responses clients, and written back on thefunctionCallpart when the next turn reaches Gemini; the skip placeholder is used only when there is no real signature. When a Gemini client receives a streamed answer, a function call carries its signature and a thought part carries its text and signature together. - The cross-format test matrix now covers reasoning for every client and upstream pair.
Tool calls written into the answer text. Some Chat-format models write a tool call into their text instead of the structured
tool_callsfield. When the request defined tools and the answer has no structured call, the gateway recognises<tool_call>{…}</tool_call>, DeepSeek’s own tool-call block markers, GLM’s<arg_key>/<arg_value>form and<function=NAME><parameter name="k">blocks, truncated endings included, and turns each into an ordinary tool call with a generated id. Only names the request defined are converted; bare JSON in prose is never touched. Streaming answers buffer from the opening marker to its close; text before the marker streams as usual. Recovered calls go through tool-call inspection like any other. A Chat client talking to a Chat upstream in the same format is passed through unchanged. A program that embeds the dialect crate and only forwards a stream can keep such text as text withSession::keep_text_calls, so a copy it records matches what its client received.Compressed bodies. A request body sent with
Content-Encoding: gzip,deflateorzstdis decoded on every route before it is read; the size limit applies to the decoded size (gw.request.decoded_body_over_limit), an encoding the gateway does not decode is refused withgw.request.unsupported_encoding, and a body that fails to decode withgw.request.bad_encoding. A compressed upstream answer, whole or streamed, reaches the client decoded. Traffic figures still count the bytes as they crossed the network.Keepalive while queued. A streamed request waiting for its key’s concurrency slot or for room in its rate window now receives the stream’s 200 and keepalive comments after 15 seconds, as a request waiting on an upstream already did; a limit that then refuses the request ends the stream with the same error the plain refusal would carry. The no-response timeout still starts only when the upstream request is sent.
Routing by time of day. A rule’s
whencan holdtime, one or more windows in the core machine’s local time, written as[days ]HH:MM-HH:MM: days aremon…sunor ranges such asmon-friandfri-mon, omitted for every day; the start is inclusive, the end exclusive,24:00allowed; an end earlier than the start is an overnight window counted on the day it starts. A malformed value is refused when the configuration loads and when a rule is saved (engine.rule_time_syntax). The dry run evaluates it at the current time and names the time it saw (fri 17:30) when the condition did not match.Context window per turn. Every turn of a session reports the context window of the model that answered it, from the model specs known now.
GET /sessions/{session_id}/turns/{turn_id}/contextestimates, over the stored request body, how many tokens the system prompt, the tool definitions, the earlier history and the last user message took, with the measured input and cache-read tokens beside them, using the same estimator the gateway uses to count tokens locally. When the body is no longer stored, the answer says so instead of estimating. - The control-plane protocol version (
-
ThinkWatch Lite v2026.10.12 GitHub ↗
Upgrade notes:
- The bundled core is now 0.68.0, with control-plane protocol 44. A remote server must be upgraded to core 0.68.0 together with the app: this version does not connect to a server on 0.67.1, and 2026.10.11 and earlier do not connect to 0.68.0.
- The request history, stored payloads and the security log are cleared on the first start.
Overview:
- The Overview is one screen of six cards — Tokens, Cost, Cache hits, Requests, First token and Traffic — each with its number and the change against the previous period. Cache hits shows how input was split between cache reads, new input and cache writes; the other cards have a small chart over time.
- Hovering any chart shows the same point in time on every chart; the value appears on the card under the pointer. First token shows the median and 95th percentile; stretches without requests are bridged with a faint dashed line.
- A strip above the cards lists what needs action — failed requests, tool calls that were cut, upstream problems, unpriced requests — and is hidden when there is nothing.
- The table below lists requests, tokens, cost, cache hits and first token by model, upstream or key, and the generation speed by model or upstream.
Traffic:
- How much each request sent to its upstreams and received from them is recorded, as it crossed the network, together with the proxy it went out through. A request’s Timeline shows Egress, Upload and Download, and hovering the token count in the Traffic table shows the upload and download.
- Upstreams › Proxies has a Traffic · 24 h column, with 7 and 30 days on hover.
Content:
- A request’s Content tab has two views, Parsed and Raw, both live while the request runs. Parsed shows the conversation, with the answer appearing as it streams, thinking and tool-call arguments included. Raw shows the request and response lines, headers and bodies, with each streamed event appended; Follow latest keeps the newest in view. Client side and Upstream side show what the client and the upstream each exchanged with the gateway, which differ when the format is converted or a plugin rewrites the request. Secrets are replaced as in what is stored.
- Places a security rule matched are marked in both views, in the colours of the security log; a folded part that holds one carries a dot.
Security log:
- Each entry opens in place to show every place the rule matched, with the text around it; the rule as it was when it matched; and exactly what was done — the placeholder a secret was replaced with, the tool call that was cut and the notice the client received, or the refusal the client received. The entries of a request’s Timeline open the same way.
- Export saves the selected period as JSON or CSV.
Codex on a ChatGPT account:
- Finished turns are recorded as finished, with their tokens and cost. Codex closes the connection as soon as it has read the end of the answer, and these turns were recorded as cancelled; longer sessions also recorded no tokens.
Upstreams:
- Some OpenAI-compatible relays and Gemini-format upstreams write
"error": nullin every streamed chunk. Such a chunk was taken for an error from the upstream: the request failed at its first chunk and moved to the next upstream, and a converted stream ended with an error and lost its text. These streams now work.
Routing:
- On the routing map, requests in flight move along their path from the key to the upstream.
Menu bar:
- The cost is written without the ≥ and ~ marks, as short as the token count (
$41.20). The opened menu still marks an estimated cost or one that is only a lower bound.
Fixed:
- Pressing Space on the Overview no longer scrolls the window into blank space.
-
ThinkWatch Enterprise v3.3.1 GitHub ↗
Streams are logged as they ended. A caller that hangs up once it has read the whole answer, as Codex does, finished the request; an error the upstream or Bedrock reports partway through a stream is logged with the status the same error has as an answer, and counts against the route only when that answer would; a Responses WebSocket turn ends at its last event. The thinkwatch-core crates move from v0.67.1 to v0.68.0.
Read before upgrading#
- Request logs and error rates change for streams. Codex streams that
were logged as cancelled (
499) are logged as finished (200) and billed on the reported usage. Streams the upstream failed partway are logged with a4xxor5xxstatus_codeinstead of200, so error rates that countstatus_code≥ 400 include them;client_statuskeeps the200the caller received. No configuration, database schema, API or Helm value changes.
Fixed#
- A caller that hangs up on the answer’s last event finished the
request. Codex closes the connection as soon as it has read
response.completed, without waiting for the stream to end, and an upstream can end its stream a while after that event: the ChatGPT Codex backend often does, and so can a relay in front of it. A stream forwarded in its own format whose caller left in that window was logged as cancelled (499,stream_outcome: client_cancelled), though the caller had read the whole answer. Once the answer’s last event has gone out — Responsesresponse.completed,response.incompleteorresponse.failed, Anthropicmessage_stop, Chat[DONE]— a caller that leaves is logged as if the stream had run to its end:200, billed on the usage the upstream reported, or, afterresponse.failed, the upstream’s failure (see the next entry). Converted streams were not affected: their last event is written once the upstream’s stream has ended. - An error the upstream reports partway through a stream fails the
request. An upstream that has answered
200can still report an error in the stream: an Anthropicerrorevent (anoverloaded_errorarrives this way), Responsesresponse.failed, anerrorin a Chat or Gemini chunk. When the stream then ended normally, the request was logged as a success (200) and counted as one by the route’s circuit breaker. It is now logged as the error it would have been as an answer, with what the upstream said:stream_outcomeupstream_error,client_status200(the status the caller’s response went out with), and thestatus_codeanderror_typethat answer would have had, read off the error’s own type or code. A rate limit or a spent quota is429(UpstreamRateLimited); a context too long or another invalid request400, an unknown model404, an overload529or503(ProviderHttpError); an error that names no status502(ProviderError). The route’s health and circuit breaker treat it as they treat that answer: a5xx, a429or a refused credential counts against the route, so a route whose streams keep failing this way opens its breaker, while a request the upstream refuses, such as one past the model’s context window, does not. This holds for streams forwarded as sent and converted ones, and for a caller that leaves after the error. Billing is unchanged: such a request is billed on the usage the upstream reported.- Error rates that count
status_code≥ 400 now include these requests. - The rows of other streams that failed after the response went out as
200— broken off, cut by tool-call inspection, refused by the upstream before its first byte — carryclient_statustoo.
- Error rates that count
- An exception Bedrock reports partway through a stream is logged as
the same exception as an answer. Bedrock reports a failure in a
stream it has started as an exception event:
throttlingExceptionwhen it throttles partway through,validationException,internalServerException,serviceUnavailableExceptionand others. Each was logged as the stream breaking off in transit (502,error_typetransport) and counted against the route’s circuit breaker. Each is now logged with the status the desktop gateway gives that exception and with what Bedrock said: throttling429(UpstreamRateLimited), an invalid request400, a model timeout504, the service unavailable503, any other exception500(ProviderHttpError). Access denied is logged as a refused credential (UpstreamAuthError), without Bedrock’s words, which name the IAM principal, as for a403answer. The route’s health and circuit breaker treat it as they treat that answer (see the previous entry), so a request Bedrock refuses as invalid no longer counts against the route. What the caller receives is unchanged, and a damaged event-stream frame is still logged astransport502. - A Responses WebSocket turn ends at its last event. On
GET /v1/responsesover a WebSocket, a turn lasted until the upstream’s stream ended, and the connection took the next turn only then, so an upstream that keeps its stream open afterresponse.completedheld up every following turn by that long. The next turn now starts at once.
Changed#
- thinkwatch-core crates (tw-bedrock, tw-breaker, tw-dialect, tw-guard)
v0.67.1 → v0.68.0. tw-dialect changes what the gateway does, tw-guard
only adds to its API, and tw-bedrock and tw-breaker do not change:
- Long usage objects are read (tw-dialect). The usage reader skipped
a usage object longer than 8 KB. The ChatGPT Codex backend’s usage
carries an
attributionbreakdown that grows with the conversation, so behind an upstream that passes it on, a longer Codex session’s requests were billed on an estimate (usage_estimated) instead of the reported counts. Usage objects up to 1 MiB are now read. One cut across chunks is read on from where it stopped, nothing nested inside a usage object is counted as a second usage, and a stream that arrives in chunks shorter than the"usage"key no longer loses it. - A
nullerror in a stream chunk is not an error (tw-dialect). Some OpenAI-compatible relays and Gemini-format upstreams write"error": nullin every streamed chunk, and the Chat and Gemini stream readers took each such chunk for an error the upstream reported. A stream converted to another format ended in an error and lost its text, and for such a stream forwarded as sent, no assembled answer was cached or captured as the response body. A Gemini caller’s tool result carrying"error": nulland nooutputwent to an upstream of another format as a failed one. Anullerror is now ignored, and a Chat chunk’s error without a message reaches the caller of a converted stream as a fixed sentence instead of the whole chunk, which could hold answer text. The gateway’s own check for an error partway through a stream (see Fixed) ignores anullerror as well. tw_dialect::convert::ends_answerrecognises an answer’s last event. The gateway uses it to tell a caller that left after the whole answer from one that left partway, and to end a WebSocket turn (see Fixed).- tw-guard adds, for the desktop gateway’s security log, the
locatemodule,content::places,content::places_textandRulePlaces,content::Rule::draw,Ledger::placeholder_of,RuleSet::custom_pattern,tools::Rule::find_all, and the fieldspattern,arguments,capped,placesandpathontools::wall::Verdict. Secret redaction, the content filter and tool-call inspection find, replace, refuse and cut exactly what they did before.Verdict::argumentsis a flagged tool call’s arguments with redacted values restored; the gateway does not log it, and its tool-call audit entries keep the fields they had.
- Long usage objects are read (tw-dialect). The usage reader skipped
a usage object longer than 8 KB. The ChatGPT Codex backend’s usage
carries an
- Request logs and error rates change for streams. Codex streams that
were logged as cancelled (
-
ThinkWatch Core v0.68.0 GitHub ↗
This release counts the network traffic between the gateway and its upstreams, lets a running request’s content be watched live, records where each security hit was and what was done about it, and fixes Codex on a ChatGPT account (its finished turns were recorded as cancelled and its longer sessions had no usage) and upstreams that write
"error": nullin every streamed chunk.Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) goes from 43 to 44. ThinkWatch Lite connects only to a core with the same protocol version: ThinkWatch Lite 2026.10.11 includes 0.67.1 (protocol 43) and does not connect to 0.68.0. A server used with it stays on 0.67.1 until the app is updated to a release that includes 0.68.0;sudo twcore upgrade --version 0.67.1 --restartswitches a server back. - The request store’s schema goes to 27: the request history, stored payloads and the security log are cleared on the first start. Recorded requests have no traffic figures, and recorded security events have no detail.
- No configuration key changes and no new message codes.
- Changed in the protocol:
HistoryRowlosesbytesand gainssent_bytes,received_bytesandegress.RequestFinishedcarriessent_bytesandreceived_bytesinstead ofbytes;RequestFailedandRequestCancelledcarry them as optional (absent when no attempt reached an upstream).RequestRoutedand in-flight requests carryegress; each attempt in the routing view carriesproxy.ReplayOriginal.bytesbecomesreceived_bytes.Summary,CostBucketandCostBucketGroupgainsent_bytesandreceived_bytes;CostBucketalso gains its token counts and the first-tokenttft_p50_ms,ttft_p95_msandttft_samplesof its bucket.CostDimgainsegress(the direct group is named"").- New endpoints
GET /latency/client(first-token latency by key) andGET /request/{id}/live(live content, an event stream;404 control.request_not_runningonce the request has ended). RequestDetailgainsheads,upstream_request_bodyandupstream_response_body.response_bodyis now what the client received; for a converted request the upstream’s own answer is inupstream_response_body.SecurityEventViewgainssession,sent_model,direction,locations,more_locations,rule_snapshotandoutcome_detail. The liveSecretsFounditems,ContentMatchedandToolCallFlaggedcarry the same detail asdetail.
Traffic. Each request records how much it sent to upstreams and received from them: request and response bodies as they crossed the network, after the gateway’s own compression and before any decoding, summed over every attempt of a failover. It also records the proxy it went out through (
egress), or none for a direct connection. The totals are in the summary, per time bucket, and grouped by model, upstream, key or proxy.Live content.
GET /request/{id}/livestreams a running request’s request and response — request and status lines, headers and bodies, the client’s side and each upstream attempt’s side — first everything so far, then each piece as it passes. Secrets are replaced and credentials masked exactly as in what is written to disk. Finished requests keep the headers, and the upstream’s side of the bodies when it differs from the client’s (a converted format, or a request with parts removed for the upstream).First-token latency over time. Every time bucket carries the median and 95th percentile of its first-token times, and first-token latency can be grouped by key.
Security log detail. Every security event now says where each hit was and what exactly was done:
- Every place the rule matched, in order, up to 50 per event (with a count of the rest): which part of the request or answer (system prompt, a message with its index and role, a tool call or tool result with the tool’s name, the answer text), its JSON path in the client’s own format, and up to 80 characters of context on either side.
- The rule as it was when it matched: a built-in rule’s id and name with the core version that evaluated it, or a custom rule’s pattern and how it matches, so the entry stays readable after the rule is changed or deleted.
- What was done: the placeholders a replaced credential was sent as; for a cut tool call, the tool, its arguments and the sentence the client received; for a refused request, the refusal; for removed characters, how many segments were removed.
- The session the request belonged to and the model that was sent.
Contexts and matches are redacted exactly like stored payloads, so no secret is written in clear. The detail is kept in the request log and expires with it. Changes to the guard settings are not written to the security log.
Codex on a ChatGPT account.
- Codex stops reading as soon as it has
response.completedand closes the connection, without waiting for the stream to end; the ChatGPT backend often ends its stream a little later. Core reported the ending only when the upstream stream ended, so a client closing first was recorded as a cancellation, and most Codex turns appeared cancelled. Once the answer’s last frame has reached the client, a request whose client then leaves is recorded as finished, with its usage and cost. - The ChatGPT backend’s
usagecarries a per-item attribution that grows with the conversation. Usage objects were only read up to 8 KB, so longer sessions recorded no tokens and no cost. They are now read up to 1 MiB.
"error": nullin a stream. Some OpenAI-compatible relays and Gemini-format upstreams write"error": nullin every streamed chunk. Core took such a chunk for an error reported by the upstream: the request failed at its first chunk and moved to the next upstream, a converted stream ended with an error and lost its text, reply plugins left those chunks untouched, and a Gemini tool result carrying"error": nullwas marked as failed. Anullerror is now ignored everywhere. An error that carries no message is recorded with a fixed sentence instead of the chunk it came in, which could hold answer text. - The control-plane protocol version (
-
ThinkWatch Enterprise v3.3.0 GitHub ↗
Requests converted for Claude now use prompt caching, and Codex works through routes that convert its requests to another format: its tools, its history and its compactions arrive. A Responses request that only OpenAI can read — one that continues a conversation kept on OpenAI’s servers, or carries a compaction OpenAI wrote — now goes to a route that speaks Responses instead of being refused. The thinkwatch-core crates move from v0.65.0 to v0.67.1.
Read before upgrading#
- Converted requests to Claude are cached, and billed as cache traffic.
A request converted for an Anthropic-format upstream, or for a Claude
model on Bedrock that AWS lists for prompt caching (Claude 3.5 Sonnet v2,
Claude 3.7 Sonnet, and Claude 4.5 and later), from a client that marks no
cache breakpoints — Codex, Chat and Gemini clients, and an Anthropic
client whose request goes to Bedrock without
cache_control— now gets them at the end of the tools, the system prompt and the last two user turns, with the 5-minute lifetime.- The upstream bills the prefix a request writes at its cache-write rate
(1.25× input at Anthropic) and what the next turns read back at its
cache-read rate (0.1×). ThinkWatch prices and counts them the same
way: cost, budgets and
tokensrate limits weigh a cache write by the model’scache_write_weightand a read by itscache_read_weight, 1.25× and 0.1× its input weight when unset. Set them on the model to match what your upstream charges. - A conversation of several turns costs less. A one-off request with a long prompt costs up to a quarter more on its input.
- Prompt token counts (
input_tokenson the log rows, quotas) include cached input in full, as before. - Requests that mark their own breakpoints, such as Claude Code’s, and requests forwarded in their own format are unchanged.
- An upstream that refuses the added breakpoints — a
400that namescache_control,cachePointor prompt caching, as an Anthropic-compatible endpoint that does not know them answers — gets the request once more without them, and later requests to it for that model leave them out from the start. Each instance remembers this until a provider or model is changed or it restarts. Breakpoints a client marked itself are never taken out, and the probes that check a route’s API format go without breakpoints.
- The upstream bills the prefix a request writes at its cache-write rate
(1.25× input at Anthropic) and what the next turns read back at its
cache-read rate (0.1×). ThinkWatch prices and counts them the same
way: cost, budgets and
- Rolling back with Codex sessions. A Codex session that compacted through a converted route on 3.3.0 carries a compaction that 3.2.1 refuses, so after a rollback, or on a 3.2.1 instance during the rollout, it cannot continue and needs a new session.
Nothing else needs action: no setting, schema, API route or Redis key changes.
Fixed#
- Responses requests that only OpenAI can read. A Responses request
that continues a conversation kept on OpenAI’s servers
(
previous_response_id,conversation,prompt,background), points at a stored item (item_reference) or carries a compaction OpenAI wrote was refused with400, even on a route that speaks Responses and would have forwarded it as sent: every request was decoded for its usage estimate, and one that could not be decoded was refused. Codex sends such a compaction in every request after compacting a session through an OpenAI upstream, so the session could not go on; and a WebSocket turn naming a response other than the connection’s last one, meant to go upstream as sent, was refused too. These requests now go to the model’s routes that speak Responses, and only a model with none refuses them, with the reason. Their input estimate counts the request’s text.
Changed#
- thinkwatch-core crates (tw-bedrock, tw-breaker, tw-dialect, tw-guard)
v0.65.0 → v0.67.1. Only tw-dialect, the format conversion, changes:
- Prompt caching for Claude on converted requests (see Read before upgrading).
- Codex tools. For some models Codex declares every tool in an
additional_toolsinput item and sends no top-leveltools. Those items were dropped, so a route converting to Anthropic, Chat, Gemini or Bedrock sent no tools and Codex could not read files or run commands. Every tool now arrives, and tool calls come back under the names and kinds Codex declared. Local shell calls, tool search, reasoning-effort changes and agent messages in the history are converted instead of dropped. - Codex compaction on converted routes. Codex compacts a long
session through a provider named
OpenAIby asking for exactly one compaction item, which a converted route could not give, so compacting failed. The upstream now writes a handoff summary, Codex receives it as its compaction, and later requests carry it back. The summary request is billed like any other. The summary travels in the item’sencrypted_content(tw1.c.…) base64-encoded, not encrypted; withaudit.body_redact_piion, captured bodies have it redacted like the rest of the body. OpenAI’s own compactions still cannot be read by an upstream of another format, and are refused saying so. - System messages in mid-conversation stay in place. A
systemordevelopermessage after the conversation has started was added to the system prompt on a converted route. It now stays where it was given: a developer message for a Responses upstream, and a user turn wrapped in<system-reminder>for Anthropic, Chat, Gemini and Bedrock upstreams. The system prompt stays the same from one turn to the next, which is what keeps the prompt cache working. verbosity(Chat) andtext.verbosity(Responses) carry across the conversion to GPT-5 and later models. Other models still leave it out.
- Converted requests to Claude are cached, and billed as cache traffic.
A request converted for an Anthropic-format upstream, or for a Claude
model on Bedrock that AWS lists for prompt caching (Claude 3.5 Sonnet v2,
Claude 3.7 Sonnet, and Claude 4.5 and later), from a client that marks no
cache breakpoints — Codex, Chat and Gemini clients, and an Anthropic
client whose request goes to Bedrock without
-
ThinkWatch Lite v2026.10.11 GitHub ↗
Upgrade notes:
- The bundled core is now 0.67.1, with control-plane protocol 43. A remote server must be upgraded to core 0.67.1 together with the app: this version does not connect to a server on 0.66.0, and 2026.10.10 and earlier do not connect to 0.67.1.
- The request history and stored payloads are cleared on the first start.
- The failover settings for a slow-starting stream are replaced by the no-response timeout. A configuration that still has
failover.next_on_slow_startorfailover.stream_start_wait_secsmakes the gateway start in safe mode; the repair it offers deletes them. - Requests converted for Claude now use prompt caching: cache writes are billed at the cache-write rate and reads at the much lower cache-read rate, and both are priced and shown with the request.
No response and abort:
- Settings › Failover has a No-response timeout, 300 seconds by default. When an upstream sends no content for that long, the request moves to the next upstream; when none is left, the client gets a timeout error instead of waiting indefinitely. After an answer has started, it ends with an error the client understands. Keep-alive pings do not count as content. It replaces the switch for moving on when a stream is slow to start.
- A running request can be stopped with Abort request in its menu on the Traffic page, and every running request of a session with Abort session. The request’s details show Aborted or No response with the time waited.
Codex:
- Through a route that converts to Claude, GLM, DeepSeek or another format, Codex keeps all its tools. For some models Codex declares its tools in a way that was dropped, and it then reported that it could not read files or run commands. Its history, compaction of long sessions and instructions in the middle of a conversation also arrive now.
- Requests converted for Claude are cached, so the turns of a long session cost much less.
Upstreams:
- Models can also be added by hand in an upstream’s dialog, in Models, for any upstream and while creating one. They are marked Manual and saved with the upstream.
Traffic:
- Long upstream, model and key names no longer widen the table: they are cut with an ellipsis and shown in full on hover, also in the row menus.
ChatGPT account:
- Signing in on another device works again. OpenAI changed the format of a value in its sign-in answer, which stopped the sign-in with an error.
-
ThinkWatch Core v0.67.1 GitHub ↗
This release fixes two problems in 0.67.0’s handling of Codex and Claude: a compaction summary could be stored without its secrets masked, and requests converted for older Claude models on Bedrock could be refused because of the cache breakpoints 0.67.0 adds.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) stays at 43: a server can be upgraded from 0.67.0 on its own. - The request store’s schema is unchanged: upgrading from 0.67.0 keeps the request history.
- The configuration format is unchanged.
- Message codes are unchanged from 0.67.0.
Compaction summaries are masked before they are stored. The summary 0.67.0 gives Codex when a converted route compacts a session travels base64-encoded in the item’s
encrypted_content, which the masking of stored payloads could not see into. A key or other secret that appeared in the conversation could reach the stored request bodies inside it. Summaries are now decoded, masked with the same rules as the rest of the payload, and put back in place before anything is written to disk.Cache breakpoints for Claude on Bedrock follow AWS’s list. 0.67.0 added prompt-cache breakpoints to every converted request for a Claude model on Bedrock, including models Bedrock does not cache. They now go only to the Claude models AWS lists as supporting prompt caching: Claude 3.5 Sonnet v2, Claude 3.7 Sonnet, and Claude 4.5 and later. When an Anthropic-format or Bedrock upstream refuses a request because of breakpoints the gateway added, the request is sent once more without them, and that upstream and model get none from then on. Breakpoints a client marks itself are never removed.
- The control-plane protocol version (
-
ThinkWatch Core v0.67.0 GitHub ↗
This release stops requests from hanging on an upstream that answers and then goes quiet, lets a running request or a whole session be aborted, and makes Codex work through routes that convert its requests to another format: its tools, history, compactions and mid-conversation instructions now all arrive. Requests converted for Claude now use prompt caching. Signing in to a ChatGPT account on another device works again.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) goes from 42 to 43. ThinkWatch Lite connects only to a core with the same protocol version: ThinkWatch Lite 2026.10.10 includes 0.66.0 (protocol 42) and does not connect to 0.67.0. A server used with it stays on 0.66.0 until the app is updated to a release that includes 0.67.0;sudo twcore upgrade --version 0.66.0 --restartswitches a server back. - The request store’s schema goes to 26: the request history and stored payloads are cleared on the first start. Recorded attempts could hold an outcome that no longer exists.
failover.next_on_slow_startandfailover.stream_start_wait_secsare removed. A configuration that still has either fails to load and core starts in safe mode; the safe-mode repair offers to delete them. The newfailover.idle_timeout_secs(default 300) replaces them.- Requests converted for Claude are cached. Prompt-cache writes are billed at the provider’s cache-write rate (25% above input for Anthropic) and reads at its cache-read rate (a tenth of input), and both are recorded and priced from the price table. Clients that mark their own breakpoints, such as Claude Code, are unchanged.
- Changed in the protocol:
- New endpoints
POST /request/{id}/abortandPOST /sessions/{id}/abort(→Aborted { requests }). - Attempts gain the outcomes
idle_timeout(with the time waited) andaborted; a request aborted by the user is recorded with the failure sourceaborted. FailoverViewgainsidle_timeout_secsand losesnext_on_slow_startandstream_start_wait_secs; theslow_startoutcome is gone.
- New endpoints
- Message codes, compared with 0.66.0:
- New:
gw.upstream.idle_timeout,gw.upstream.idle_timeout_mid_stream,gw.request.aborted,control.request_not_running,control.session_not_running. - Removed:
gw.slow_start,config.slow_start_too_short.
- New:
No response. An upstream that accepts a request and then sends nothing used to keep it forever: there is deliberately no overall timeout, and the only check, moving on when a stream was slow to start, was off by default and never applied to the last upstream. Now, when
failover.idle_timeout_secs(300 seconds by default, 30 to 3600) pass without any content — text, reasoning or a tool call; keep-alive pings do not count — the gateway gives up on that upstream. Before anything has reached the client, the request moves to the next upstream and the silent one counts as a failure for its pause; when none is left, the client gets a timeout error in its own format instead of waiting. After content has started, the answer ends with an error event the client understands. A whole (non-streamed) answer counts the same time until it arrives. A streamed answer is held for at most 15 seconds; after that the client receives its response headers and keep-alive comments, so it does not give up while the gateway is still able to move to the next upstream.Abort. A running request, or every running request of a session, can be aborted from the control plane. The upstream call is dropped at once, the client gets an error, the request is recorded as aborted by the user, and the upstream is not set aside.
Codex through converted routes.
- For some models Codex sends no top-level
toolsand declares every tool in anadditional_toolsinput item. Those items were dropped, so a route converting to Anthropic, Chat, Gemini or Bedrock sent no tools at all, and Codex reported that it could not read files or run commands. Every tool now arrives, and tool calls come back to Codex under the names and kinds it declared. - Local shell calls, tool search, mid-session reasoning-effort changes and agent messages in the history are converted instead of dropped.
text.verbosityreaches GPT-5 and later models on Chat upstreams. - Compacting a long session works on converted routes: the upstream writes a handoff summary, Codex receives it as its compaction, and later requests carry it back. OpenAI’s own encrypted compactions still cannot be read by other upstreams and are refused clearly.
- Developer messages that arrive in the middle of a conversation stay where they are instead of joining the system prompt, so the system prompt stays the same from turn to turn and caching keeps working.
Prompt caching for Claude. A request converted for Claude from a client that marks no cache breakpoints — Codex, Chat and Gemini clients — now gets them on the tools, the system prompt and the last two user turns, so each turn reads what the previous one cached. This applies to Anthropic-format upstreams and to Claude models on Bedrock.
ChatGPT account on another device. OpenAI’s device-code answer now gives its polling interval as text (
"5"), which stopped the sign-in with “could not be read”. Numbers written as text are now accepted there and in the other sign-in answers that carry durations. - The control-plane protocol version (
-
ThinkWatch Lite v2026.10.10 GitHub ↗
Upgrade notes:
- The bundled core is now 0.66.0, with control-plane protocol 42. A remote server must be upgraded to core 0.66.0 together with the app: this version does not connect to a server on 0.65.0, and 2026.10.9 and earlier do not connect to 0.66.0.
- Request history is kept.
- Models added by hand are now checked when the configuration loads. An entry with
*or?, spaces around it, a duplicate, or more than 256 characters is refused and the gateway starts in safe mode, naming the upstream and the model; the earlier manual model list accepted such entries. Plain model ids load unchanged.
Upstreams:
- Models can be added by hand: Add models… in an upstream’s model list. A model the upstream serves but leaves out of its list — newer models an account backend hides, a relay that lists only some — is then routed to it like any listed model: routing and failover, aliases, routing rules that name a model, a key’s allowed models, the model list clients see, the dry run and the speed test.
- Models added by hand are marked Manual and can be removed from the list, with Undo in the confirmation. The summary counts them apart from the ones the upstream lists. For an upstream that lists no models, the same dialog replaces the manual model list.
Menu bar:
- The Menu bar setting has a fourth choice, Hidden, which takes the item off the menu bar. Opening ThinkWatch Lite again from Finder or Spotlight shows the main window; the gateway keeps running in the background.
MCP:
- Editing a client’s configuration, such as adding a line to
~/.codex/config.toml, no longer reports the remote MCP servers and other findings that were already there as new. Only findings that are actually new are reported.
-
ThinkWatch Core v0.66.0 GitHub ↗
This release lets an upstream’s models be added by hand. An upstream that answers
/v1/modelsbut leaves out a model it serves — an account backend that hides newer models, a relay that lists only some — can now be given that model, and requests for it are routed there like for any listed model.Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) goes from 41 to 42. ThinkWatch Lite connects only to a core with the same protocol version: ThinkWatch Lite 2026.10.9 includes 0.65.0 (protocol 41) and does not connect to 0.66.0. A server used with it stays on 0.65.0 until the app is updated to a release that includes 0.66.0;sudo twcore upgrade --version 0.65.0 --restartswitches a server back. - The request store’s schema is unchanged: upgrading from 0.65.0 keeps the request history.
providers[].modelsis now checked when the configuration loads. An entry that is blank, has spaces around it, contains*or?, appears twice, or is longer than 256 characters is refused, and core starts in safe mode with an error naming the upstream and the model; fix or remove the entry. Before, such entries were accepted as written. A configuration that never wrotemodels, or wrote plain model ids, loads unchanged.- Changed in the protocol:
- New endpoint
PUT /provider-manual-models(ManualModelsSave { provider, models, base_version }→ConfigWritten): replaces one upstream’s models added by hand, in the order given, and leaves the rest of its entry alone. An empty list removes the key. ModelRowgainsmanual(the model was added by hand) andlisted(the upstream’s own list has it).
- New endpoint
- Message codes, compared with 0.65.0:
- New:
config.manual_model_blank,config.manual_model_padded,config.manual_model_wildcard,config.manual_model_duplicate,config.manual_model_too_long.
- New:
Models added by hand.
providers[].modelsused to be a fallback, read only when the upstream did not answer/v1/models. It now always counts: its models are offered by that upstream next to the ones the upstream lists, and are the whole list when the upstream lists none. A model on both lists appears once. Everything that asks which models an upstream has treats them the same way: routing and failover, aliases, routing rules that name a model, a key’s allowed models, the gateway’s/v1/models, the dry run and the speed test.models_onlystill limits what is used. Changing the list takes effect on reload, without asking the upstream for its list again. - The control-plane protocol version (
-
ThinkWatch Enterprise v3.2.1 GitHub ↗
The Helm chart and the Compose file now pull the published images: they named
ghcr.io/thinkwatch/…, which this project does not publish to, so a default install could not pull. A TOTP code now logs in once, and log rows are no longer lost while ClickHouse is unreachable. Five reads from ClickHouse returned wrong or missing data and now return what is stored: the SLO latency percentiles, a route’s latency history, the times on traces and log bodies, a trace’s app events, and the log lists for a range written with aT; and a failed read is now an error or a logged warning instead of an empty answer. The server also reads from ClickHouse 26.9 and later, the console shows its own version again, and the dependencies move to current releases, several of them for security advisories. The thinkwatch-core crates move from v0.62.0 to v0.65.0, which makes outbound redaction and the content filter faster without changing what they find.No setting, schema or API route changes, and instances of 3.2.0 and 3.2.1 can run side by side during a rollout.
Read before upgrading#
- Image names in your own files. A values file or
--setflag of your own that namesghcr.io/thinkwatch/think-watch-serveror…-webkeeps overriding the chart’s new default, and a copy ofdeploy/docker-compose.ymlkeeps the old name: change them toghcr.io/thinkwatchproject/…. A pinned image tag has nov:3.2.1, notv3.2.1.
Fixed#
- Image names. The Helm chart (
image.server.repository,image.web.repository) anddeploy/docker-compose.ymlnamedghcr.io/thinkwatch/think-watch-serverand…-web, which this project does not publish to and which refuses anonymous pulls, sohelm installwith the default values anddocker compose pullfailed. Releases are published toghcr.io/thinkwatchproject/think-watch-serverand…-web, and both files now name them. The tag examples in the chart’s README andvalues-production.yaml.examplewrotev0.2.0; published tags have nov, and the examples now say3.2.1. - SLO latency percentiles.
GET /api/admin/sloreported p50, p95 and p99 latencies next to zero: the percentiles kept the column’s integer type and were read as floating-point numbers. They now give the latencies in milliseconds. - Route latency history. The latency sparkline on the routing page
(
GET /api/admin/models/{model_id}/route-history) was always empty: the query’s column types differed from the ones the server read, and the failure fell back to no data. It now shows each minute’s p50 and p95, and a gap for a minute whose requests all failed before a latency was recorded. - Times on traces and log bodies. The trace view and the gateway and
MCP log body views put the month name where the minutes belong
(
2026-10-06T04:October:52), because%Min ClickHouse’sformatDateTimeis the month name. They now read04:31:52. - App events on traces. A trace never listed app log events: the query that finds them compared a text column with a time, ClickHouse refused it, and the refusal showed as no events.
- ClickHouse 26.9 and later. ClickHouse 26.9 compresses query results
with ZSTD unless the client names a method, and the server’s client
reads only LZ4, so every read failed with
incorrect magic number. The server now names LZ4 on every query, which every ClickHouse version supports. Query results are also checked against the types the server reads them into, so a query whose types drift fails instead of returning garbage; every query was checked against ClickHouse 26.8 and 26.9. - Log rows kept through a ClickHouse outage. The log writer is meant to
keep a batch that ClickHouse did not take and retry it on the next tick,
up to 1000 entries per table. A batch that failed while being sent —
ClickHouse restarting or unreachable, the usual case — had already been
emptied, so its gateway, MCP, audit, access or app log rows were lost
while the server logged
retaining N entries for retry. They are now kept and retried. A batch that ClickHouse stored but whose answer never arrived is written a second time. - Time ranges on the log lists. On the gateway, MCP, audit, app and
access log lists, a
fromwrittenYYYY-MM-DDTHH:MM:SS, the form the API’s error message suggests, dropped every row of that day fromitemswhiletotalstill counted them: the list compared the times as text. The console sendsYYYY-MM-DD HH:MM:SSand was not affected. - ClickHouse failures no longer read as no data. The trace view, the
usage-license meter (
/api/admin/usage-license) and the per-period series and previous-period totals of the usage and cost statistics turned a failed ClickHouse query into an empty or zero answer, with nothing in the log. They now answer500and log the error. The dashboard’s request and active-key tiles and the call counts on the MCP server list still answer without the part that failed, so the page loads, and now log a warning naming the query. - The console’s version. The sidebar of the web console showed
v0.0.0in every released web image, because the image build could not see the version file it reads. It now shows the release.
Security#
- A TOTP code logs in once. A code accepted at login could log in again,
with the password, for as long as it stayed valid: up to 90 seconds with
the step of clock drift allowed either side, so a code read over a
shoulder or a shared screen was enough. The last time step accepted for
each user is now kept in Redis (
totp_last_step:<user id>, for three minutes), and a code whose step is not later is refused, the code that turned TOTP on included. Logging in twice within one 30-second step needs the next code; one step of clock drift is still accepted. A refused replay is audited asauth.totp_failedwith"replayed": true. Instances of 3.2.0 do not check, so this holds once the rollout is done. - rand 0.10.3 (RUSTSEC-2026-0097)
and xxhash-rust 0.8.19
(GHSA-6g2r-675j-hx59).
Neither affects how ThinkWatch uses the crate: it installs no
loglogger that draws random numbers, and it hashes with xxh3’s default secret only. - The web console’s build tools: brace-expansion 2.1.7 and 5.0.12, and ip-address 10.7.3 (Dependabot alerts on dependencies of eslint and the shadcn CLI). None of them is part of the console that is served.
Changed#
- thinkwatch-core crates (tw-bedrock, tw-breaker, tw-dialect, tw-guard) v0.62.0 → v0.65.0. Secret scanning and the content filter do much less work per request: they search bytes directly, skip plain ASCII where no rule can match it, and lowercase text in runs. Core tests each fast path against the code it replaced on randomly generated text, so they find exactly what they found before.
- The bundled ClickHouse (Helm
clickhouse.image,deploy/docker-compose.yml) moves from 26.3 to 26.8, both long-term-support releases. An upgrade restarts it on 26.8 with its data. ClickHouse does not promise that an older version reads what a newer one wrote, so to roll back to 3.2.0 later, keepclickhouse.imageat 26.8. - Dependencies move to current releases, among them sqlx 0.9, clickhouse 0.15, tower-http 0.7, utoipa 6, jsonwebtoken 11, argon2 0.6, aes-gcm 0.11, p256 0.14 and totp-rs 6. Password hashes, encrypted secrets, issued tokens, request signatures and TOTP codes from 3.2.0 keep verifying, and what 3.2.1 writes verifies on 3.2.0; known-answer tests cover each.
- Tests now hold two things that could otherwise break unnoticed: a login for an address with no account still spends the time of a password check (so its timing does not tell which addresses have accounts), and a column added to a ClickHouse log table comes with a default (without one, the previous release’s inserts into that table fail during a rolling upgrade or after a rollback).
- The server image is built with Rust 1.99, and the web image serves from nginx 1.31 (was 1.29), built with Node 24.21 and pnpm 12. Building from source needs Rust 1.94.1 or newer (was 1.85).
- Image names in your own files. A values file or
-
ThinkWatch Lite v2026.10.9 GitHub ↗
Upgrade notes:
- The bundled core is now 0.65.0, with control-plane protocol 41. A remote server must be upgraded to core 0.65.0 together with the app: this version does not connect to a server on 0.64.x, and 2026.10.8 and earlier do not connect to 0.65.0.
- Request history is kept. The first start adds indexes to the request store, which takes about a second on a large history.
Menu bar and tray menu:
- Open ThinkWatch Lite is always the first item, whatever else the menu shows.
- A Today block replaces the header and the three large numbers: today’s tokens in large type, requests, failures and cost below them, and a small chart of tokens per hour. Its top line shows the gateway state and the generation speed, and the server’s name when connected to a remote core.
- One row per quota window the upstream reports, with when it resets.
- Copy Gateway Address shows the address it copies. Install Version is the first item of its group when a new version is available.
Faster:
- The Traffic page opens in a fraction of the time with thousands of requests in the list, uses far less memory, and stays smooth while requests arrive.
- The launch screen appears after a small part of the app has loaded; pages load as they are opened.
- Live updates and the Overview chart use less CPU, and nothing is redrawn while the window is hidden or minimised.
- The gateway processes large requests about four times faster, and no longer pauses other answers that are streaming at the same time.
- The conversation view of a long session fetches only the new turns instead of the whole conversation each time.
- On macOS, watching the clients’ configuration files no longer wakes the app for every file change in the home folder.
- Over a remote connection, the Overview’s figures are fetched in parallel.
Fixes:
- Forwarded request bodies keep the client’s bytes and key order. Removing
metadata.user_id, and a routing rule or alias changing the model, output limit or thinking, used to rewrite the body with every object’s keys in alphabetical order. - When one day’s stored payloads exceeded the size cap, the hourly clean-up deleted that whole day, including the session being viewed. The current day now loses only its oldest payloads.
- Sessions older than the newest 500 open again.
- The Configuration file and Version history dialogs show an error with Retry when they cannot load, instead of loading forever.
- If the menu bar item, tray icon or a window cannot be created at startup, the app reports it as a notice and keeps the gateway running, instead of quitting.
- The routing dry run shows each round-robin member’s share as the gateway computes it; members sitting the round out show none.
- On Windows, the ring around an in-progress request’s status dot now animates.
- Numbers are written the same way everywhere: latency as
1182ms, dates as10-08, token counts as1.5k.
-
ThinkWatch Core v0.65.0 GitHub ↗
This release makes the gateway do much less work per request, keeps large requests from holding up other streams, fixes two ways stored request bodies and sessions could go missing, and stops forwarded bodies from having their keys reordered. On macOS, watching configuration files no longer wakes the process for every file change in the home folder.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) goes from 40 to 41. ThinkWatch Lite connects only to a core with the same protocol version: ThinkWatch Lite 2026.10.8 includes 0.64.1 (protocol 40) and does not connect to 0.65.0. A server used with it stays on 0.64.1 until the app is updated to a release that includes 0.65.0;sudo twcore upgrade --version 0.64.1 --restartswitches a server back. - The request store’s schema is unchanged: upgrading from 0.64.1 keeps the request history. The first start on an existing store adds three indexes, which takes about a second for a store of 270,000 requests and grows the file by about 15%.
- The configuration format is unchanged.
- Changed in the protocol:
GET /sessions/{id}/transcripttakesTranscriptQuery(from_turn, optional: only the turns from that index on). A client written for protocol 40 that sendsnullis refused.Transcriptgainstotal_turnsandsettled_turns, the number of leading turns that will not change any more; asking fromsettled_turnsnext time returns only what is new.DryRunCandidategainsshare: the part of a load-balance group’s requests the candidate gets with the current weights and factors. It is null outside load-balance groups and for members sitting the round out.
- Message codes are unchanged from 0.64.1.
Less work per request. With the default settings, a 2 MiB request from Claude Code used to cost about 45 ms of processing before its first byte went upstream, and an 8 MiB one about 180 ms; they now cost about 11 and 46 ms. The body is parsed once instead of three times, the checks for known client requests and for keys in the text search bytes directly, the content filter skips plain ASCII, and the scan that finds keys to redact no longer copies the body just to note what it found. Bodies of 1 MiB or more are analysed on a thread of their own, so answers streaming on the same worker no longer pause: the longest gap between relayed chunks while 8 MiB requests arrived fell from about half a second to under 40 ms.
Forwarded bodies keep the client’s bytes. Taking out
metadata.user_id(sent by Claude Code on every request) and applying a routing rule or alias that changes the model, output limit or thinking used to rewrite the whole body with every object’s keys in alphabetical order, tool schemas and tool inputs included. Only the changed fields are now edited; everything else reaches the upstream as the client sent it.Stored bodies and sessions.
- Body clean-up no longer deletes the current day. When one day held more than
body_max_bytesof bodies, the hourly clean-up emptied that day’s folder, including the session being looked at. Older days are still removed whole; the current day loses its oldest requests until it fits. - A session older than the newest 500 opens again; it used to be reported as not found.
- The session list, the last-seen time of each client and the routing page’s counts use indexes instead of reading every request: the session list went from about 180 ms to about 14 ms on a store of 270,000 requests.
- A session’s conversation is built once and then extended with new requests, instead of rereading every stored body each time: 830 ms → 6 ms for a 300-turn session.
- Requests are recorded on a thread of their own with a queue separate from the app’s live updates, so a slow app window can no longer cause requests to go unrecorded.
File watching on macOS. Watching a folder used to deliver every change anywhere below it, so watching the home folder for
~/.claude.jsonwoke the process several times a second. Folders are now watched with kqueue and only their own entries count, with the files of interest watched directly for changes in place. Idle over 30 seconds, the watch plan ThinkWatch Lite uses went from 105 wake-ups to 4. Linux and Windows are unchanged. - The control-plane protocol version (
-
ThinkWatch Lite v2026.10.8 GitHub ↗
Upgrade notes:
- The bundled core is now 0.64.1. The control-plane protocol stays at 40: this version connects to a remote server on 0.64.0 or 0.64.1, and 2026.10.7 connects to 0.64.1.
- Request history is kept.
Gateway:
- Requests over 2 MiB are no longer refused. A conversation holding a few inline screenshots passed 2 MiB and was refused with 413 from then on, compaction requests included, so the session could not be compacted back under the limit. The gateway’s limit is now its intended 256 MiB.
- A request over the limit is answered with 413 in the client’s own error format. A request’s body is read only after its source address and key are checked.
Updates:
- A new version found by the daily check is listed in the notices, in grey, and shown as a system notification when notices are set to that. Before, it was a one-off system notification, and once it was dismissed only the menu item was left. Clicking the notice opens the update window. Finding a newer version replaces the notice for the older one.
-
ThinkWatch Core v0.64.1 GitHub ↗
This release lifts a 2 MiB limit on request bodies that the gateway never meant to have. A conversation holding a few inline screenshots passed 2 MiB and was refused from then on, compaction requests included, so the session could not be compacted back under the limit (#295).
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) stays at 40: ThinkWatch Lite 2026.10.7 connects to 0.64.1, and a server can be upgraded on its own. - The request store’s schema is unchanged: upgrading from 0.64.0 keeps the request history.
- The configuration format is unchanged.
- Message codes, compared with 0.64.0:
- New:
gw.request.body_declared_too_large,gw.request.body_over_limit,gw.request.body_unreadable. - Removed:
gw.request.body_too_large, replaced by the first two: one for a declared length over the limit, which names the length the client declared, and one for a body found over the limit while reading, which has no size to name.
- New:
Request size. The gateway’s only limit on a request body is now its own 256 MiB. The body used to be read by the web framework before the gateway saw the request, under the framework’s default 2 MiB limit: anything larger got a plain-text
413 Failed to buffer the request body, was not in the client’s error format, and left no record.The body is now read only after the source-address check, the key check and the POST check pass, so a connection that is not allowed in can no longer make the gateway hold its body in memory first. A request whose
Content-Lengthis already over the limit is refused without reading the body. The limit is enforced on the bytes actually received, so aContent-Lengththat understates the body does not get around it. An oversized request is answered with 413 in the client’s own error format (request_too_largefor Anthropic clients), andx-thinkwatch-error: request. - The control-plane protocol version (
-
ThinkWatch Lite v2026.10.7 GitHub ↗
Upgrade notes:
- The bundled core is now 0.64.0, which speaks control-plane protocol 40. A remote server has to run core 0.64.0 too: this version does not connect to 0.63.0, and 2026.10.6 and earlier do not connect to 0.64.0.
- Request history is kept.
- Clients that list the gateway’s models in their own configuration (opencode, Pi, oh-my-pi, Qwen Code, Grok Build) are flagged on the Clients page for an update once, because the configuration now also carries each model’s specs.
Clients:
- Taking over opencode, Pi, oh-my-pi, Qwen Code or Grok Build now writes each model’s specs next to its name, from the price table or the specs set on the Upstreams page: the context window, and where the client uses them, the output limit, whether the model reasons, and whether it takes images. Before, only names were written and the clients used their own defaults, for example 128K context and no reasoning levels for every model in Pi, and no automatic compaction in opencode.
- Only what is known is written; a client uses its own default for the rest. Qwen Code and Grok Build get no output limit, because both send it with every request. Qwen Code gets no reasoning setting, because its reasoning settings change what every request sends.
- When a model’s specs change, for example after setting them by hand, the Clients page asks to update the client’s configuration, as it already did when the model list changed.
Upstreams:
- Under “Specs…” in an upstream’s model list, a model can also be marked as reasoning or not, and as taking images or not, or left to the price table.
-
ThinkWatch Core v0.64.0 GitHub ↗
This release tells clients more about each model. The model listing now says, for every model it knows, how much it can write in one answer, whether it reasons and whether it takes images, besides its context window. An upstream’s model can have the last two set by hand, like the context window and output limit before.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) goes from 39 to 40. ThinkWatch Lite connects only to a core with the same protocol version: ThinkWatch Lite 2026.10.6 includes 0.63.0 (protocol 39) and does not connect to 0.64.0. A server used with it stays on 0.63.0 until the app is updated to a release that includes 0.64.0;sudo twcore upgrade --version 0.63.0 --restartswitches a server back. - The request store’s schema is unchanged: upgrading from 0.63.0 keeps the request history.
- The configuration format only gains fields (
providers[].model_specs.*.reasoningand.image_input); every 0.63.0 configuration loads. - Changed in the protocol:
ModelRowgainsreasoning,reasoning_source,image_inputandimage_input_source(SpecSource).ModelSpecSavegainsreasoningandimage_input. An entry is removed when all four fields are empty. A client written for protocol 39 drops the two new fields when it saves a model spec.
- Message codes, compared with 0.63.0:
- New:
config.model_spec_nothing_set. - Removed:
config.model_spec_empty, replaced byconfig.model_spec_nothing_setbecause the sentence now names four fields.
- New:
What the model listing carries.
GET /v1/modelsadds three fields to each model in the OpenAI and Anthropic shapes, each only when it is known:max_output_tokens, the most tokens one answer can have;supports_reasoning, whether the model reasons;input_modalities,["text", "image"]or["text"].
The Gemini shape adds
thinking. The values come from the same place as the context window: the upstream’s hand-written model spec first, then the price table. When neither says, the field is left out rather than guessed. Clients then use their own defaults: a client told “does not reason” hides its reasoning levels, and that is not something to guess. The price table now keeps “not stated” apart from “no” for reasoning, and reads image support from the dataset’ssupports_vision.Reasoning and image input set by hand.
providers[].model_specstakesreasoningandimage_input(true or false) next tocontext_windowandmax_output_tokens, each overriding the price table on its own.PUT /provider-model-specsets them. The speed test also uses the result: a model set by hand to reason gets room to reason in its probe request, as models the price table marks as reasoning already did. - The control-plane protocol version (
-
ThinkWatch Enterprise v3.2.0 GitHub ↗
Redis Cluster and Redis over TLS now work, so managed Redis services can be used. Usage limits work as documented: token limits refuse, an API key’s limits count on that key, and a refused request counts against nothing. Server instances can start together against one database, a restart no longer clears captured bodies older than 30 days, and the Helm network policy allows the ports the database URLs name. The thinkwatch-core crates stay at v0.62.0.
Read before upgrading#
- Rate-limit windows start empty. Rate-limit counters move to new Redis keys (one hash per counter, tagged so that Redis Cluster can run them), and the counts from before the upgrade are not carried over: every window starts empty and fills from the first request after the upgrade. The old keys expire by themselves within two window lengths. Users’ budget counters are kept; API keys’ are not (see below).
- Route health starts fresh. A route’s samples, circuit breaker and
lifetime request count move to new keys for the same reason, so every route
starts closed with nothing counted. The old lifetime counters never expire;
redis-cli --scan --pattern 'route_health:[0-9a-f]*' | xargs redis-cli delremoves them (the new keys startroute_health:{). - An API key’s own budgets start again. 3.1.0 counted a key’s budget on
its owner’s counter (
budget:user:<user id>:…); 3.2.0 counts it on the key’s own (budget:api_key_lineage:<lineage id>:…), which starts at zero. A key with a monthly budget of its own can spend all of it again in the rest of the month. - Limits are looser while the rollout runs. Pods of 3.1.0 and 3.2.0 count rate limits and route health on different keys, so each sees only its own version’s requests: limits let more through, and circuit breakers can disagree, until the last 3.1.0 pod is gone.
- Token limits start refusing. A
tokensrate limit never refused a request before. It now does once its window is full, so a deployment with token limits will see429s where it saw none. - A key’s limits no longer replace its owner’s. A rate limit or budget set on an API key used to take the place of the owner’s limit for the same window or period. Both now apply, each on its own counter: a key’s limits can narrow what its owner may do through that key, never widen it. A key given a higher limit than its owner to give it more room needs the owner’s limit raised instead.
rediss://connects over TLS. 3.1.0 connected to arediss://URL over plain TCP. Arediss://URL pointing at a port without TLS now fails at start: point it at the TLS port, or writeredis://.- The certificate must name the host in a subjectAltName. One that names
it only in its CN, which
redis-cliaccepts, is refused. - Cluster nodes that announce IP addresses their certificates don’t name can’t be reached.
- The certificate must name the host in a subjectAltName. One that names
it only in its CN, which
budget_unavailablein dashboards. With Redis down andsecurity.rate_limit_fail_closedon, a request that has a budget is refused asbudget_unavailable, notrate_limiter_unavailable: budgets are checked first.- Upgrade with an ordinary rollout. Earlier versions don’t take the lock that now makes instances set up the schema one at a time, so don’t restart instances of the old version while the first one of this version starts.
- No transaction-mode pooler in front of Postgres. Schema setup now holds a
Postgres session-level advisory lock, which a pooler in transaction mode
(PgBouncer
pool_mode = transaction) can leave held, and every later start then waits for it: pointDATABASE_URLat Postgres itself or at a pooler in session mode (the Helm chart’s README has the details).
Fixed#
- Token limits refuse requests. A
tokensrate limit never refused anything, and stopped counting once a request would have taken it past its limit. A request is now refused once the window’s recorded usage reaches the limit, and every request’s tokens are recorded after it, even past the limit — a window can overshoot by what was in flight when it filled. - Several request limits at once. With two or more
requestslimits on a user (per minute and per hour, say), every request that passed was counted twice, and only one of the limits could refuse; withsecurity.rate_limit_fail_closedon, every request was refused asrate_limiter_unavailable. - An API key’s limits count on that key. They were counted on its owner’s
counter, which every key of the owner shared, and the usage the console
reads for a key (
/api/admin/limits/api_key/{id}/usage) was always 0. Each key now has counters of its own, rate limits and budgets alike, for the gateway and the MCP gateway, and its usage shows what it used. - A refused request counts against nothing. A request refused by a spent budget, or by one rate limit after another had passed, was still counted against the request limits. Budgets are now checked first and every rate limit in one step, so a refused request leaves every counter as it was.
Retry-Aftersays when to retry. A429from the gateway’s own limits saidRetry-After: 30whatever the limit. It now gives the seconds until the window has room for another request, or until a spent budget’s period ends (the next midnight, Monday or 1st of the month, UTC). A spent budget also sendsx-should-retry: false, so the OpenAI and Anthropic SDKs don’t retry it by themselves. The body stays in the caller’s API format.- Redis Cluster. The rate-limit, route-health and quota scripts touched
keys of several hash slots, which a Redis Cluster refuses: on a cluster, rate
limits silently stopped applying (or refused every request with
security.rate_limit_fail_closed), circuit breakers never tripped, and cache invalidation reached one node only. Every key a script touches now shares a hash tag, pattern deletes scan every node, and the Helm chart’s README describes aredis-cluster://URL. - Redis over TLS. The server was built without TLS for Redis: a
rediss://URL was used as plain TCP, so against a Redis that requires TLS — ElastiCache with in-transit encryption, Upstash, Azure Cache for Redis and Redis Cloud among them — the server did not start (Failed to connect to Redis: Protocol Error: Expected string.).rediss://andrediss-cluster://URLs now connect over TLS and check the certificate against the system’s CAs, as upstream HTTPS does. For a Redis whose certificate a private CA signed,REDIS_CA_CERTnames a PEM file with that CA, which is then trusted alone; the Helm chart sets it from a Secret given inredis.caSecret. The chart’s README describes both. - Several instances starting at once. Server instances starting together
against one database — a Helm
replicaCountabove 1, a rolling upgrade, an autoscaler adding pods — applied the schema side by side, and all but one could exit withDatabase migration failed: apply db/schema.sql: … deadlock detected(on an empty database:duplicate key value violates unique constraint "pg_extension_name_index"). With ClickHouse, the rollups that an instance fills from the logs when it finds them empty (cost_rollup_hourly,provider_health_5m,mcp_server_call_counts) could be filled by each of them, counting every request once per instance on the cost pages, the dashboard and the MCP server list. Instances now set up Postgres and ClickHouse one at a time, under Postgres advisory locks: the others wait, loggingAnother instance is setting up the database schema; waiting for it to finish, then find it done. An instance that dies holding a lock releases it with its connection. The Helm chart’s startup probe allows a pod 125 s to start instead of 35 s, set instartupProbe(the chart’s README says when to raise it), and an attempt to reach a ClickHouse that doesn’t answer gives up after 5 s instead of the system’s TCP timeout. - Captured bodies kept as long as configured. With ClickHouse and
audit.body_retention_daysabove 30, every server start could clear the captured request and response bodies older than 30 days (gateway_logs.request_body/response_body,mcp_logs.tool_arguments/tool_result; the rows themselves stayed). The start-up table setup set those columns’ TTL to 30 days each time, and ClickHouse applies a TTL to the data already stored as soon as it is set, before the server put the configured TTL back a moment later. The setup now gives these columns a TTL only when it creates them, so a restart leaves the configured one in place. Bodies already cleared cannot be recovered. The log tables’ own TTLs (data.retention_days_*) were not affected. - Helm network policy and databases on other ports. With
networkPolicy.enabled, the server could reach PostgreSQL only on5432, Redis on6379and ClickHouse on8123, whatever theirexternalUrlsaid, so a database on another port was blocked — Azure Cache for Redis over TLS (6380), a managed Postgres or a ClickHouse on a port of its own: the server could not start, or started without writing to ClickHouse. The allowed ports now followpostgres.externalUrl,redis.externalUrlandclickhouse.externalUrl: every port a URL names, and the client’s default for its scheme where it names none.networkPolicy.extraEgressadds egress rules as written, for ports no URL names (Redis Cluster nodes announcing other ports, an upstream or MCP server on a port other than443). The chart’s README describes both. Port9000, ClickHouse’s native protocol, is no longer allowed: the server reaches ClickHouse over plain HTTP only (anhttp://URL; HTTPS is not supported). An S3 endpoint on9000(RustFS, MinIO) configured outside the chart needs a rule innetworkPolicy.extraEgress.
-
ThinkWatch Lite v2026.10.6 GitHub ↗
Upgrade notes:
- The bundled core is now 0.63.0, which speaks control-plane protocol 39. A remote server has to run core 0.63.0 too: this version does not connect to 0.62.0, and 2026.10.5 and earlier do not connect to 0.63.0.
- Request history is kept.
- A configuration edited by hand that lists an upstream twice in a strategy group, or a name that is not an upstream, no longer loads; safe mode names the group and the member.
- A key’s concurrency limit now counts streamed answers until they end, so a key at its limit waits more often than before.
Strategy groups:
- Load-balance groups take a weight per member, from 1 to 100, and a choice of how to distribute: by ratio, by speed, by reliability, or both. Speed is the time to first token, reliability the recent success rate; both adjust the weights, and an upstream without enough measurements counts as average.
- Ongoing conversations stay on their upstream and count toward its share. The route test shows each member’s weight, speed, success rate and share, and predicts the next upstream.
Upstreams:
- An upstream can have a concurrency limit. A conversation in progress waits for a free slot; other requests go to the next upstream, and when every upstream is full the request waits, then fails with a clear message.
- A model’s context window and output limit can be set by hand under “Specs…” in the upstream’s model list, for relays the price table does not know or gets wrong. A manual value is marked and replaces the price table’s.
- Check-up findings (a different model name in answers, input reported high or low, few cache reads) now appear on the upstream’s row; the separate Check-up tab is gone.
Failover settings:
- “Move to the next upstream when the start times out” (off by default): a streamed answer that has sent nothing within the wait moves to the next upstream, if one can take it. The last upstream keeps waiting.
- “Wait for a free slot at most” (30 seconds by default) is the total time a request waits for a full upstream or a key’s per-minute or per-hour limit.
Keys:
- A key can have usage limits: requests, tokens or cost (USD) per minute, hour, day, week or month, all of which must hold. A token limit can count cache reads. Day, week and month reset at local midnight, on Monday and on the 1st.
- The key dialog shows each limit’s usage and reset time, and lists the models the key can use that have no price; they count as $0. Keys at a limit are marked in the list, and a notification comes at 80% and when a limit is reached.
Traffic:
- The attempt chain shows an upstream given up for a slow start, with the input tokens it may have billed, upstreams skipped at their concurrency limit, and time spent waiting for a slot.
- Requests refused by a key’s usage limit or because every upstream was full are labelled as such.
- On a Responses WebSocket connection, each turn is now a request of its own, with its usage and cost.
Also:
- The menu bar’s menu follows the appearance chosen in the app instead of the menu bar’s tint, so it no longer opens light in Dark Mode over a bright wallpaper.
-
ThinkWatch Core v0.63.0 GitHub ↗
This release gives routing more to go on and keys a budget. Load-balance groups can weigh their members and favour faster or more reliable upstreams; a stream that is slow to start can move to the next upstream; an upstream can be capped at a number of concurrent requests; an upstream’s model can have its context window and output limit set by hand; and a gateway key can have usage limits per minute, hour, day, week or month. Each turn on a Responses WebSocket now counts as a request of its own.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) goes from 38 to 39. ThinkWatch Lite connects only to a core with the same protocol version: ThinkWatch Lite 2026.10.5 includes 0.62.0 (protocol 38) and does not connect to 0.63.0. A server used with it stays on 0.62.0 until the app is updated to a release that includes 0.63.0;sudo twcore upgrade --version 0.62.0 --restartswitches a server back. - The request store’s schema is unchanged: upgrading from 0.62.0 keeps the request history.
- The configuration format only gains fields, and every 0.62.0 configuration loads, with one exception: a group that lists the same upstream twice, or lists a name that is not an upstream, is now refused when the configuration loads (it always was when saved through the control plane). Such a configuration starts in safe mode, which names the group and the member.
url-testordering can change: its samples now measure the time from sending a request to an upstream to the first content of the answer (see below).- A gateway key’s
max_concurrentnow also counts streamed answers until they end; before, a request left the count when the response started. A key at its limit therefore queues more than before. - Responses WebSocket connections are recorded one row per turn instead of one row per connection.
- Changed in the protocol:
- Groups:
GroupViewgainsweights(every member’s weight for a load-balance group, empty otherwise) andbalance_by(BalanceBy);GroupInputtakes both (weightsmissing = all 1). The dry-run’sDryRunCandidategainsweight,ttfb_ms,success_rateandbalance_factor;DryRunResultgainsbalance_by. - Upstreams:
ProviderViewandProviderInputgainmax_concurrent.ModelRowgainscontext_window_source,max_output_tokensandmax_output_tokens_source(SpecSource:price_tableormanual). New:PUT /provider-model-spec(ModelSpecSave→ConfigWritten). - Failover:
FailoverViewgainsnext_on_slow_startandslot_wait_secs. - Attempts:
AttemptViewgainsqueued_ms,skipped(ServeSkip::Busy) andusage(AttemptUsage);AttemptOutcomegainsslow_start. - Keys:
ClientViewgainslimits(KeyLimitView) andunpriced_models;KeyInputgainslimits(KeyLimitInput;LimitPer,LimitMeasure). New event:Event::KeyLimitAlert. - A client written for protocol 38 drops weights and limits when it saves a group or a key.
- Groups:
- Message codes, compared with 0.62.0:
- New:
config.key_limit_cache_reads,config.key_limit_cost_too_small,config.key_limit_duplicate,config.key_limit_empty,config.key_limit_not_positive,config.key_limit_retention,config.key_limit_two_measures,config.model_spec_blank_model,config.model_spec_empty,config.model_spec_wildcard,config.model_spec_zero,config.provider_concurrency_range,config.slow_start_too_short;control.group.balance_not_load_balance,control.group.weight_not_load_balance,control.group.weight_not_member,control.group.weight_out_of_range;engine.group_balance_not_load_balance,engine.group_unknown_upstream,engine.group_upstream_twice,engine.group_weight_not_load_balance,engine.group_weight_out_of_range;gw.busy_all,gw.busy_upstream,gw.key_limit.cost_per_period,gw.key_limit.cost_rolling,gw.key_limit.requests_per_period,gw.key_limit.requests_rolling,gw.key_limit.tokens_per_period,gw.key_limit.tokens_rolling,gw.slow_start,gw.ws.upstream_closed. - None removed.
- New:
Weights and distribution in load-balance groups. A member of a
load-balancegroup may carry a weight:providers: [{ name: relay, weight: 7 }, official].- A weight is a whole number from 1 to 100; a plain name is 1 and is written back as a plain name, so existing configurations stay as they are. Weights are refused on other group types.
- Requests are spread by smooth weighted round-robin, so 7 : 3 interleaves instead of arriving in runs. A request that stays on an upstream to continue its conversation counts toward that upstream’s share, so a weight is the long-run share of requests. Paused and full members sit out; when all are, all take part.
groups[].balance_byadjusts the weights automatically:latencygives faster upstreams more,healthgives upstreams that fail less more,latency-healthboth;weights(the default) uses the weights alone. The factors multiply the weights. An upstream without enough measurements counts as average, so a new one is tried without being flooded, and a flaky one still gets the occasional request, so its recovery is noticed.- The dry-run shows each member’s weight, time to first content, success rate and factor, and predicts the next upstream exactly as the gateway will choose it.
- WebSocket upgrades are now ordered the same way as HTTP requests.
What speed means. The samples behind
url-testand the latency factor now measure the time from sending a request to an upstream to the first content of its answer, for streamed answers, wherever the upstream sits in the list of candidates. Before, they ran from the moment the request reached the gateway, so they included waits and earlier failed attempts, and they ended at a point that depended on the upstream’s position.url-testuses the startup connection test until an upstream has three real samples; the latency factor uses real samples only. Each upstream also keeps a recent success rate (its last 50 attempts within 30 minutes, with at least 5), classified like the failures that pause an upstream.Moving on from a slow start. With
failover.next_on_slow_start, a streamed answer that has sent no content afterstream_start_wait_secsis cancelled and the next upstream is asked, provided one can take the request at that moment; otherwise the current upstream keeps streaming. The last upstream always waits. The slow upstream is not paused. The abandoned attempt appears with the outcomeslow_startand the input tokens the upstream may have billed. It is off by default; with it on,stream_start_wait_secsmust be at least 5, and 30 or more suits models that think before they write.A concurrency limit per upstream.
providers[].max_concurrentcaps the requests sent to an upstream at once, for relays and accounts that limit concurrency.- A conversation that is using its upstream’s cache waits for a free slot, then moves on. Other requests skip a full upstream at once.
- When every upstream that could serve a request is full, it waits for the first free slot. If none frees in time and no attempt reached an upstream, the answer is a 429 with
Retry-After; otherwise the client gets the last real failure. failover.slot_wait_secs(default 30, 0 for no wait) is the total a request waits, for upstream slots and for a key’s per-minute and per-hour limits together.- Waiting is not a failure: it neither pauses an upstream nor counts against it.
Model specs set by hand.
providers[].model_specsgives a model’s context window and output limit on that upstream, field by field, for relays whose models the price table does not know or gets wrong. The values replace the price table’s wherever core reports or uses them: the model listing in every API shape, the control plane’s model rows, aliases, the re-routing of a conversation that outgrows its context window, and the output limit filled in when a request is converted to the Anthropic format.PUT /provider-model-specsets or clears one model’s entry.Usage limits per gateway key.
clients[].limitslists limits that must all pass:{ per: minute | hour | day | week | month, requests | tokens | cost: N }.- Minute and hour are rolling windows; day, week (from Monday) and month follow the machine’s local time.
- Tokens are uncached input, cache writes and output; a token limit with
cache_reads: truecounts cache reads too. Cost is in US dollars, at least 0.01; models without a price and upstreams withbilling: freecount as 0. - A request in flight reserves its estimate, and its recorded usage replaces the estimate when it ends. A request that never reached an upstream counts for nothing.
- Totals are rebuilt from the request store at startup, and when a period’s start moves, such as after a time-zone change. The store must keep rows for the whole period (
retention.row_daysat least 1, 7 or 31 days). - A day, week or month limit that is used up answers 429 with
Retry-Afteruntil the reset andx-should-retry: false, so the official SDKs don’t retry it; OpenAI-format bodies carryinsufficient_quota. A minute or hour limit waits within the request’s wait, then answers 429 with the exact time to retry. - The control plane reports each limit’s usage and reset time, and the models a key with a cost limit can reach that have no price.
Event::KeyLimitAlertfires at 80% and when a limit is reached.
WebSocket turns. Each
response.createon a Responses WebSocket is now a request of its own: it has a row with the turn’s usage and cost, takes its own key permit and upstream slot only while it runs, is checked against the key’s limits, and adds to the upstream’s speed and success samples. A refused or busy turn is answered withresponse.failedand the connection stays usable. An upstream that closes in the middle of a turn fails it. A Realtime connection is still one row, now carrying the usage of its responses, which counts against the key’s limits when the connection closes.Groups list each upstream once. Loading the configuration refuses a group that lists an upstream twice, which made failover try it again and gave it extra turns in load-balance, and a member that is not an upstream, which used to drop out of the group silently.
- The control-plane protocol version (
-
ThinkWatch Lite v2026.10.5 GitHub ↗
Upgrade notes:
- The bundled core is now 0.62.0, which speaks control-plane protocol 38. A remote server has to run core 0.62.0 too: this version does not connect to 0.61.0, and 2026.10.4 and earlier do not connect to 0.62.0.
- Request history is kept.
- A key’s model scope now also applies when no upstream reports a model list, on WebSocket connections such as Codex’s, and to every backup in failover. A client that used a model outside its key’s scope through one of these gaps is now refused.
Model aliases:
- Upstreams has a new Aliases tab. An alias is another name for the same model: list the names the model has on each upstream, and every upstream that offers one of them can serve it under its own name, as backups for one another.
- When the same Claude model has different names across upstreams (official, Bedrock, Vertex, OpenRouter), the tab and the new-alias dialog suggest grouping them, and a dot on the tab shows there is a suggestion.
- An alias can also be created from an upstream’s model list. The dialog shows which upstream receives which name, and warns when the name takes over another upstream’s model or when a connected client treats the name specially.
- Clients see aliases in their model lists, and answers carry the name the client asked for.
- In a key’s model scope, allowing an upstream model also allows the aliases that list it.
Routing:
- A rule can forward to specified models: an upstream and one of its models, with backups tried in order. The model is sent as written.
- A model condition written for an upstream model also matches the aliases that list it.
- Traffic marks requests sent under an alias or to a specified model, and shows the model each attempt sent.
Claude Desktop:
- Takeover follows the model-name rule of the current Claude Desktop, including 1M spellings.
- When no upstream offers a Claude model, takeover asks which upstream model Claude Desktop should use and adds a routing rule for its own key. Cancelling the takeover removes the rule.
Also:
- Tool-call inspection has a new built-in rule, “Read or change ThinkWatch’s own data”, for tool calls whose path or command points into ThinkWatch’s data directory, where every upstream key is kept. A document that only mentions the directory does not count.
- Model lists sent to clients carry each model’s context window, which Claude Desktop, Hermes and others use to size conversations.
- Upstream addresses that end in their own API version, such as Zhipu’s
…/api/paas/v4and Ark’s…/api/v3, work without a second/v1. - System notifications name every built-in tool-call rule; two used to show their internal id.
-
ThinkWatch Enterprise v3.1.0 GitHub ↗
The thinkwatch-core crates move from v0.59.0 to v0.62.0. Two changes reach the gateway: tool-call inspection gains a built-in rule for ThinkWatch’s own data directory, and an upstream whose base URL already ends in an API version is no longer sent a second one.
Read before upgrading#
- The new tool-call rule
thinkwatch-datais on and set to cut off, like the other high-risk built-in rules. A deployment whose tool-call inspection is in enforce mode starts cutting off answers whose tool call reads or changes one of the paths below; one in observe mode only records them. Set the rule to record, or switch it off, on the security page to keep the previous behaviour.
Added#
- Tool-call inspection:
thinkwatch-data(Read or change ThinkWatch’s own data). A tool call whose path or command points into ThinkWatch’s data directory (~/.thinkwatch,%APPDATA%\ThinkWatch,/var/lib/thinkwatch,/etc/thinkwatch) — where the desktop gateway keeps every upstream key and its own protection settings. Text that only mentions the directory, such as a document being edited, does not count. The security page lists it with the other built-in rules.
Fixed#
- Upstream base URLs that end in their own API version (
…/api/paas/v4,…/api/v3) now receive requests under that version instead of a second/v1appended to it, which returned 404. Requests and connection tests both use it.
Changed#
- thinkwatch-core crates (tw-bedrock, tw-breaker, tw-dialect, tw-guard) v0.59.0 → v0.62.0.
- The new tool-call rule
-
ThinkWatch Core v0.62.0 GitHub ↗
This release adds model aliases: one name for the same model across upstreams, each upstream receiving the name it uses for it. Routing rules can also pin requests to a particular upstream’s model, answers carry the model name the client asked for, and the model listing tells clients each model’s context window. Tool-call inspection gains a rule that guards ThinkWatch’s own data directory, and an upstream URL that ends in its own API version no longer gets a second one.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) goes from 37 to 38. ThinkWatch Lite connects only to a core with the same protocol version: ThinkWatch Lite 2026.10.4 includes 0.61.0 (protocol 37) and does not connect to 0.62.0. A server used with it stays on 0.61.0 until the app is updated to a release that includes 0.62.0;sudo twcore upgrade --version 0.61.0 --restartswitches a server back. - The request store’s schema is unchanged: upgrading from 0.61.0 keeps the request history.
- The configuration format only gains fields: a top-level
aliasestable, andtoin a routing rule may now be a list of pinned models. Every 0.61.0 configuration loads unchanged. - Answers to requests whose model was renamed on the way (by an alias, a rule’s
set.model, a pinned model or a plugin) now carry the name the client asked for, as long as the upstream answered with the model it was sent. Before, they carried the upstream’s name. - A key’s
allowis now enforced in three places it used to be skipped: when no upstream reports a model list, on WebSocket connections (Responses and Realtime), and on every failover candidate, including pinned models. A client that was using a model outside its key’sallowthrough one of these gaps is now refused. - Changed in the protocol:
- New:
GET /aliases(AliasesView),POST /aliasesandPUT /aliases/{name}(AliasSave→ConfigWritten/AliasWritten),DELETE /aliases/{name},POST /alias-preview(AliasPreviewRequest→AliasPreview) andGET /aliases/{name}/usage(AliasUsage). RuleView.toandRuleInput.toare aRuleTarget: an upstream or group name as before, or a list ofPinnedModel { provider, model }.KnownModelgainsalias(the alias’s models, when the entry is an alias) andaliases(the aliases that list a model);GET /modelslists aliases.ModelRowgainsaliases.- The dry-run result gains
candidate_models, oneDryRunCandidate { provider, sent_model, model_via }per candidate. ServeSkipgainsnot_allowed: a candidate skipped because the key may not use the model it would be sent.- Moved, with the endpoint names unchanged:
POST /providers/test→POST /provider-test,POST /providers/preview→POST /provider-preview,POST /proxies/test→POST /proxy-test,POST /plugins/inspect→POST /plugin-inspect,POST /plugins/rewrite→POST /plugin-rewrite,POST /plugins/confirmed→POST /plugin-confirmedandPUT /plugins/order→PUT /plugin-order. At the old paths they shadowed an upstream, proxy or plugin of the same name: an upstream namedtestorpreview, or a proxy namedtest, could not be edited or deleted.order,inspect,rewriteandconfirmedare now valid plugin ids.
- New:
- Message codes, compared with 0.61.0:
- New:
config.alias_blank_model,config.alias_chained,config.alias_duplicate,config.alias_empty_name,config.alias_no_models,config.alias_only_itself,config.alias_reserved,config.alias_wildcard;engine.pinned_blank_model,engine.pinned_duplicate_provider,engine.pinned_empty,engine.pinned_unknown_provider,engine.pinned_with_provider_would_be,engine.pinned_with_set_model;gw.model.alias_unserved,gw.model.alias_unserved_rewritten,gw.model.pinned_not_allowed,gw.model.pinned_not_offered,gw.plugin.alias_unserved,gw.ws.alias_unserved,gw.ws.alias_unserved_rewritten;control.replay_alias_unserved. - Removed:
config.plugin.reserved_idandcontrol.plugin.reserved_id, since no plugin id is reserved any more.
- New:
Model aliases. An alias is another name for the same model, written in a top-level
aliasestable as the alias and the names upstreams use for it, in order.- An upstream serves an alias when it offers one of the listed names within its
models_only, and receives the first such name in the list. Every upstream that serves an alias takes part in failover for it, each receiving its own name. GET /v1/modelslists aliases next to the upstream models; the original names stay available.- An alias takes precedence over an upstream model of the same name that it doesn’t list.
- A key’s
allowand a rule’swhen.modelwritten for an upstream model name also cover the aliases that list it; written for an alias, they cover only the alias. set.modelmay name an alias. Prices, the upstream check-up and each recorded attempt use the name sent upstream; the request log keeps the name the client asked for.- Replay, the inference speed test and plugin renames resolve an alias for each upstream the same way.
- WebSocket connections follow the same rules as HTTP requests. Each
response.createon a Responses connection is sent under the connected upstream’s own name for an alias (a pinned model or a phase-two rename as written), is checked against the key’sallowwith the same inheritance, and gets the rules’set.max_tokens,set.thinkinganddenyas evaluated for that request; one that can’t be sent is answered withresponse.failedand the connection stays open. A Realtime connection (/v1/realtime) is routed by the model in its query string, which the key must be allowed to use and which reaches the upstream under its own name. Before, WebSocket connections were not checked againstallow, and the rules’ parameter rewrites and model conditions did not apply to them. - The control plane manages aliases (
/aliases), previews a draft (which upstream would receive which name, upstream models of the same name it would take over, other upstreams’ names for the same Claude model) and renames an alias together with the keys’allowentries and the rules’when.modelandset.modelthat name it. When the old name stays in the alias’s list,allowandwhen.modelkeep it: the name of a listed model already covers the alias.
Pinned models. A routing rule’s
tomay list upstreams with the model to send each, tried in order. The model is sent as written: aliases andset.modeldo not apply. A rule can so send one key’s requests to one upstream’s model even when an alias of the same name points elsewhere.The model name in answers. When the model sent upstream differs from the one the client asked for and the upstream answered with the model it was sent, the answer carries the client’s name: in all four API formats, streamed or not, and in the
openai-modelandx-openai-modelheaders. An answer naming a different model than the one sent is passed on unchanged, so a substitution by the upstream stays visible. The request log and the upstream check-up keep the upstream’s own answer.Context windows in the model listing. Each model in
GET /v1/modelscarries its context window where the price table knows it:context_window,context_lengthandmax_input_tokens, plussupports_1mon the Anthropic shape andinputTokenLimit/outputTokenLimiton the Gemini shape. Claude family tiers now includefableandmythos.ThinkWatch’s own data directory. Tool-call inspection has a new built-in rule,
thinkwatch-data, for a tool call whose path or command points into ThinkWatch’s data directory (~/.thinkwatch,%APPDATA%\ThinkWatch,/var/lib/thinkwatch,/etc/thinkwatch). Underenforceit cuts the response off. Text that only mentions the directory, such as a document being edited, does not count. The configuration reference now says what protectsconfig.yamlon the machine.Upstream URLs with their own version. An upstream whose base URL already ends in an API version (
…/api/paas/v4,…/api/v3) receives requests under that version instead of a second/v1appended to it, which used to return 404. Requests, connection checks and model-list fetches all use it. - The control-plane protocol version (
-
ThinkWatch Lite v2026.10.4 GitHub ↗
Upgrade notes: the bundled core stays at 0.61.0, so a remote server running 0.61.0 keeps working with this version and nothing on the server has to change. Request history is kept.
Linux: no more blank window on newer distributions:
- The AppImage no longer carries its own Wayland libraries. It used to bring those of the system it was built on (Ubuntu 22.04), loaded ahead of the system’s, and on distributions with a newer graphics stack, such as Arch, the window could stay blank. The app now uses the system’s Wayland libraries, which every Linux desktop has.
- On a Wayland session the app now runs on Wayland itself instead of through X11 (XWayland).
- Thanks to sunnysab on LINUX DO, whose work packaging the app showed the fix.
Under the hood: Tauri 2.12.1.
-
ThinkWatch Lite v2026.10.3 GitHub ↗
Upgrade notes:
- The bundled core is now 0.61.0, which speaks control-plane protocol 37. A remote server has to run core 0.61.0 too: this version does not connect to 0.60.0, and 2026.10.2 and earlier do not connect to 0.61.0.
- Request history is kept.
- If 2026.10.2 showed “core is not running” after the update because
config.yamlstill setpassthroughorrouteunderclient_probes:, this version shows the line at fault and repairs it in one click.
Configuration errors:
- When core cannot read
config.yaml, the app shows the file, the line, the reason and the line itself, instead of “core is not running”. Core runs in safe mode: client requests are not forwarded, and the configuration can still be edited and rolled back. - One-click repair: a value outside its choices goes back to its default, and an unknown field is removed. The changes are listed before they are made, and the previous version is kept in Version history. The same repair is offered when an edit to the file is refused while the gateway keeps running on the previous version.
- Once a working configuration is saved in safe mode, the gateway starts forwarding again on its own.
- A value outside its choices and a misspelled field, the two commonest mistakes, are described in the interface language.
- When core still cannot start, the app shows the reason core gave.
-
ThinkWatch Core v0.61.0 GitHub ↗
This release makes safe mode work for the case it exists for: a configuration that does not load. Core serves the control plane anyway, says which line is wrong, and can repair the two commonest mistakes in one step. Before, a safe-mode core exited on the same error as a normal start, and the desktop app could only say that core had stopped.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) goes from 36 to 37. ThinkWatch Lite connects only to a core with the same protocol version. ThinkWatch Lite 2026.10.2 includes 0.60.0 (protocol 36) and does not connect to 0.61.0. A server used with it stays on 0.60.0 until the app is updated to a release that includes 0.61.0;sudo twcore upgrade --version 0.60.0 --restartswitches a server back. - The request store’s schema is unchanged (25): upgrading from 0.60.0 keeps the request history.
- The configuration format is unchanged.
- Changed in the protocol:
- New:
GET /config/repair(ConfigRepairPlan→ConfigRepair:base_versionand a list ofConfigFix) andPOST /config/repair(RepairConfig:ConfigRepairRequest { base_version }→ConfigWritten). A web view may call both. ConfigFixhaskind(unknown_valueorunknown_field),field(a path such asproviders[0].protocol),line,value(masked) andnow(the default the field goes back to, when it is a single value).- In safe mode,
Status.config_rejectedis set from the start when the configuration does not load, andStatus.gateway_addris null as before.
- New:
- Message codes, compared with 0.60.0:
- New:
config.unknown_variant(field,value,expected),config.unknown_field(field,expected) andcontrol.config_not_repairable. - A value outside its choices and an unknown field used to come as
config.unparsablewith serde’s sentence indetail; they now use the two new codes. Other parse errors still useconfig.unparsable.
- New:
Safe mode with a configuration that does not load.
twcore serve --safeused to load the configuration before anything else and exit when it did not load. It now serves the control plane with a stand-in configuration: the control key comes from the file, so a client that reads it from the same file can connect, and everything else is a default. The data plane does not start.Status.config_rejectedsays from the start which line is wrong, in the same form as for an edit that was refused while running: the stage, the message, the line and the line itself with secrets masked.- Saving a configuration that loads (through the control plane, a rollback, a repair, or by editing the file) swaps it in as usual. A client then restarts core in normal mode.
- A file in which the control key cannot be found, such as one that is not valid YAML, still makes
serve --safeexit, since nobody could connect to the control plane.
One-click repair.
GET /config/repairlists what a repair would change, andPOST /config/repairmakes those changes and writes the file.- Only two kinds of mistake are repaired, and each repair removes one key. A value outside its choices, such as
titling: passthrough, goes back to the field’s default. An unknown field, such as a misspelled name, is removed. A section left empty is removed with its last key. Comments, layout and every other key are kept. - A repair is offered only when the repaired configuration loads. A syntax error, a missing field and an error in the configuration as a whole (such as two upstreams with the same name) are left to the user.
- The repair is computed again from the file on disk when it is applied, and refused with 409 when the file has changed since the plan. It goes through the same path as any other write, so the previous version is in the history and can be rolled back.
- The control-plane protocol version (
-
ThinkWatch Lite v2026.10.2 GitHub ↗
Upgrade notes:
- The bundled core is now 0.60.0, which speaks control-plane protocol 36. A remote server has to run core 0.60.0 too: this version does not connect to 0.59.0, and 2026.10.1 and earlier do not connect to 0.60.0.
- Request history is kept.
- Auxiliary requests have two modes now (see Auxiliary requests). If a kind under Routing › Auxiliary was ever set to Pass through or Use routing,
config.yamlholdspassthroughorroutefor it underclient_probes:. Core does not start with either, and the app shows that core is not running until each one is changed toforwardor its line is deleted. 2026.10.3 shows the line at fault and repairs it in one click. Kinds that were never changed are not in the file and are not affected. - Release files are now named
ThinkWatch-Lite-<version>-<darwin|windows|linux>-<arch>…. Installed copies keep updating as before.
Auxiliary requests:
- Each kind of auxiliary request is either answered locally or forwarded. Forward replaces Pass through and Use routing, which both went through the routing rules and differed only in whether a rule’s Auxiliary request condition could match.
- A forwarded auxiliary request goes through the routing rules like any other request, and a rule’s Auxiliary request condition matches it with no further setting, for example to send titles to a lower-cost upstream.
- The rule dialog points out when the condition names a kind that is answered locally, since such requests never reach routing.
- A dry run of a forwarded auxiliary request evaluates the rules.
Windows portable build:
- One zip each for x64 and ARM64. Extract it and run “ThinkWatch Lite.exe”; no administrator rights are needed.
- Data lives in a data folder next to the program, separate from the installed copy.
- Updates install after a single confirmation, without an elevation prompt.
Windows:
- Only one ThinkWatch Lite runs at a time. Opening a copy from another location says so and offers to stop the running one and start this one instead; the running one waits for requests in progress before it quits.
- System notifications no longer depend on a Start menu shortcut; thinkwatch:// links and launch at login always point at the running copy.
- Uninstalling from Windows Settings first restores taken-over clients. Ticking “Also delete data” now deletes the configuration, API keys and request history, and an incomplete cleanup is reported.
- Clients connected by another ThinkWatch Lite are marked as such and are restored from that copy.
- A missing WebView2 runtime is reported with a download link.
- The interface and system dialogs follow per-monitor scaling and stay sharp on scaled displays.
Fixes:
- Running an older version after a newer one no longer clears the request history: the older version records nothing for that run and leaves the history for the newer one.
The Features page describes each of these in full.
-
ThinkWatch Core v0.60.0 GitHub ↗
This release gives client helper requests two modes instead of three: answered locally, or forwarded. A forwarded helper request goes through the routing rules like any other request and carries its class, so a rule with
when.intenttakes effect as soon as it is written. An older core no longer clears a request history written by a newer one.Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) goes from 35 to 36. ThinkWatch Lite connects only to a core with the same protocol version. ThinkWatch Lite 2026.10.1 includes 0.59.0 (protocol 35) and does not connect to 0.60.0. A server used with it stays on 0.59.0 until the app is updated to a release that includes 0.60.0;sudo twcore upgrade --version 0.59.0 --restartswitches a server back. - The request store’s schema is unchanged (25): upgrading from 0.59.0 keeps the request history.
client_probestakesinterceptorforward.passthroughandrouteare gone, and a configuration that still sets either does not load. Change them toforward, or delete the line: health checks and warm-ups default tointercept, titles, topic detection and suggestions toforward. A configuration that never setclient_probesis not affected.- Changed in the protocol:
ProbeModeisintercept|forward.DryRunOutcomeno longer haspassthrough. A dry run of a forwarded helper request evaluates the rules and ends inroute,deny,no_matchorunavailable, like any other request.RouteSave.route_probesis removed. Saving a route no longer changesclient_probes.
- Message codes, compared with 0.59.0:
control.unknown_probe_classis removed, along withroute_probes. No code is added and no sentence changes.
Helper requests. Clients such as Claude Code send requests of their own: health checks, warm-ups, session titles, topic detection and suggestions.
passthroughandrouteboth sent such a request through the routing rules. They differed only in whether it carried its class, and so in whether awhen.intentcondition could match it. The names suggested thatpassthroughskipped routing, and the dry run said so too: it stopped before the rules and reportedpassthrough, while the gateway evaluated the rules for that request like for any other.forwardreplaces both. The request carries its class, sowhen: { intent: titling }orwhen: { intent: assistant_internal }matches it without any other setting.interceptis unchanged: the gateway answers the request itself, nothing is sent upstream, and no rule is evaluated. Awhen.intentcondition never matches a class set tointercept.- Defaults are unchanged in effect: health checks and warm-ups are answered locally; titles, topic detection and suggestions are forwarded.
Request history across versions. Core rebuilt the request store, bodies included, whenever its schema differed from the build’s own, in either direction. An older core opening a store written by a newer one therefore cleared a history the newer version would have read again: after installing an older release over a newer one, or with a second copy of the app on the same machine that has not updated yet. Now only an older store is rebuilt. A newer one is left as it is: core records nothing while it runs, forwarding is unaffected,
/storagereportsrecording: false, and the newer version opens the store unchanged later. - The control-plane protocol version (
-
ThinkWatch Lite v2026.10.1 GitHub ↗
Upgrade notes:
- The bundled core is now 0.59.0, which speaks control-plane protocol 35. A remote server has to run core 0.59.0 too: this version does not connect to 0.57.x, and 2026.10.0 and earlier do not connect to 0.59.0.
- Request history is cleared on the first start, as the request store is rebuilt. Configuration, keys and upstreams are kept.
- The Hidden characters and Output limit guards are removed (see Three security guards). If either was changed from its factory setting, set it back before updating: Hidden characters to Observe with both of its rules on, Output limit to Off with a limit of 100,000 characters. Otherwise core does not start and the app runs in safe mode until
hidden_text:andoutput_limit:, with the lines indented under them, are deleted from thesecurity:section ofconfig.yaml. - Two plugins come with the app and are added turned off.
Plugins:
- The new Plugins page runs JavaScript plugins that change requests before they go to an upstream and answers before they reach the client.
- Plugins run in a sandbox inside core, with no access to files, the network or the environment. A plugin sees only the parts of a request it was granted, and never a real secret: recognized secrets are replaced with placeholders before it runs and put back afterwards.
- A plugin is one file that also holds its settings, its scope and what happens when it fails. Its editor has a Settings tab and a Code tab that are saved together, and adding a plugin opens the same editor.
- A plugin can be tried on a recent request, moved in the run order by dragging, and followed through its logs and statistics. The Traffic page marks requests a plugin changed, and a request’s detail shows it before and after the plugins.
- Only a plugin that can change tool calls asks for confirmation in a system dialog: to be installed, turned on, have its code changed, or have a change to its file approved.
- The two included plugins are Answer in a chosen language and Convert WSL and Windows paths.
Session conversations:
- A session has a new Conversation tab that replays it turn by turn: messages, folded thinking, tool calls with their results, and images.
- What cannot be shown is marked with the reason, such as a body past the retention period.
Three security guards:
- Outbound redaction, tool-call inspection and the content filter now cover what the five guards did. Hidden characters are built-in content-filter rules, which delete the characters instead of refusing the request, and the output limit is gone.
- Content rules can refuse a request, delete what they match or only record it, and can match Unicode code points.
- Custom redaction rules can name their placeholder. Email addresses and Chinese mainland mobile numbers are new built-in redaction rules, off by default.
- Tool-call inspection has two new rules: a credential sent to a host other than its own provider, and a local file uploaded to a remote host.
Stored requests:
- Request and answer bodies are stored with recognized secrets already replaced, whatever the redaction mode.
- Answers are kept up to 4 MiB (it was 256 KiB), and body storage defaults to 5 GiB (it was 2 GiB). Retention settings above 7 days, 90 days or 2 GiB now take effect; a fixed cleanup used to cut them back every hour.
- An error that an upstream answered, such as a 400, now counts as a failed request, with the upstream’s message as the reason.
Fixes:
- On Windows, the gateway no longer needs the Visual C++ Redistributable to start.
- A request holding many values that look like secrets no longer slows the gateway down.
The Plugins and Features pages describe each of these in full.
-
ThinkWatch Enterprise v3.0.0 GitHub ↗
The request guards — outbound redaction, tool-call inspection and the content filter — now share their rule model with the desktop gateway: the same policy shape, built-in rule catalog, validation, rule view and sample trial, from thinkwatch-core. Each guard has three modes (off, observe, and one named for what it does: replace, cut off, enforce), and the content filter can delete what a rule matches as well as refuse or record it, and match by code point. Hidden characters become content filter rules, and the per-model output length guardrail becomes a cap on the output tokens a request may ask for. Settings saved by an earlier version are converted at the first start: read the first section before deploying. It is a major release because setting keys, console API routes and the
modelstable change; the conversion keeps each deployment’s behaviour.Read before upgrading#
-
Back up
system_settingsandmodelsfirst, and plan for no way back to 2.2. The upgrade converts the guard settings and drops a column (next item). Version 2.2 cannot run on the converted database: its settings are gone, so it would start on its seeded defaults, and it can no longer read the models table. Take a copy before deploying:pg_dump --data-only --table=system_settings --table=models "$DATABASE_URL" > thinkwatch-2.2-guards.sql -
Stop every replica of 2.2 before the first 3.0 one starts. A 2.2 replica still running when 3.0 converts finds its settings gone (it then filters and redacts nothing) and cannot rebuild its router; one that restarts writes its seeded defaults back. 3.0 does not convert those a second time — it removes them at its next start and logs a warning — but the 2.2 replica runs on them until then. With the Helm chart (release
thinkwatch, namespacethinkwatchhere):kubectl -n thinkwatch scale deployment/thinkwatch-server --replicas=0 kubectl -n thinkwatch wait --for=delete pod \ -l app.kubernetes.io/name=think-watch,app.kubernetes.io/component=server --timeout=5m helm upgrade thinkwatch deploy/helm/think-watch -n thinkwatch # with your usual values kubectl -n thinkwatch scale deployment/thinkwatch-server --replicas=<replicas you run>The last command matters when
autoscaling.enabledis on: the chart then leaves the replica count alone, and it stays at 0. With Docker Compose,docker compose stop serverbefore pulling and starting the new image. -
The old guard settings are converted at the first start, and behave as before.
security.content_filter_patterns,security.hidden_text,security.pii_redactor_patternsandsecurity.tool_inspectionbecomesecurity.content,security.redactandsecurity.inspect_tools, and are deleted, in one transaction during the boot migration. The conversion is recorded insecurity.legacy_converted(when, which version, what it converted); with that record present, old keys or the old column that show up again are removed, never converted, so the policies in force are not overwritten. A content rule identical to a built-in rule becomes that rule, switched on, any other a custom rule; a list with rules in it runs in enforce mode with every built-in rule it did not name switched off. The four seeded PII patterns become the built-in rules for the same data (cn-resident-id,bank-card,email,cn-mobile-phone), any other pattern a custom rule whose label is its old placeholder prefix. Whatever the old runtime was skipping (a rule that did not compile, a built-in rule id it did not know, anoutput_guardrailsvalue it could not read) is left out, each with a warning in the start-up log. A model’soutput_guardrailslength cap becomesmax_output_tokens(below), and the column is dropped. To see what was converted, read the three keys from Settings orsystem_settingsafterwards; the start-up log lists them too. -
Placeholders are written
<<TW_EMAIL_1>>, not{{EMAIL_1}}. The label of a custom rule is upper case letters, digits and underscores (an old prefix is converted:REDACTED-SSN→REDACTED_SSN); the built-in identity number and bank card rules useID_NUMBERandCARD_NUMBER. Anything that looked for the old form in answers or logs needs the new one. -
Redaction searches the whole request, not only the user’s messages: the system prompt, earlier answers and tool-call arguments are redacted too. Base64 payloads (images, files, signatures) are still left alone. Rules run on the request as it is sent, as JSON, where a custom pattern’s match ends at a quote or a backslash: a pattern written to match across a
"in the decoded text needs rewriting. -
Built-in credential rules start replacing on deployments that were redacting. API keys and tokens with a known prefix, private keys, JWTs and connection-string passwords are built-in rules that ship switched on. A deployment whose PII list had patterns in it runs redaction in enforce mode after the upgrade, so these values are now replaced as well. Switch the ones you do not want off on the console’s security page. With an empty PII list, redaction converts to observe mode: it records what it finds and changes nothing.
-
The built-in rules that replace the seeded PII patterns are stricter. An identity number has to have a real province code, a real date of birth and a matching check digit; a card number a known network’s prefix and length and a valid Luhn digit, and published test card numbers do not count. Numbers the old regexes took for them — any 18 digits, any 16 — are no longer replaced. A deployment that relied on the looser match can add its old regex back as a custom rule.
-
Tool calls are judged as the client receives them, and two built-in rules are new. Inspection now reads a tool call converted to the caller’s format and with redacted values restored — what the client would run — where it used to read the placeholders. The new
secret-to-unknown-hostrule cuts (in enforce mode) a call that sends a recognised API key or private key to a host that is neither local nor the key’s own provider;upload-file-to-hostrecords a call that uploads a local file to an outside host. A deployment running tool-call inspection in enforce mode starts cutting the first; add it todisableif that is not wanted. -
“Warn” and “log” are one action now, “record only”, and hidden characters are content filter rules:
unicode-tagsandbidi-controls, pluszero-widthandprivate-use, which ship off.security.hidden_text: blockconverts to those two rules refusing,warnandlogto recording,offto switching them off. Recording writes an audit event:hidden_text: logused to reach only the application log, and now writesgateway.content_flagged. -
A deployment with no content rules starts recording. An empty content filter list converts to observe mode with the built-in rules that ship on, so requests matching them (
ignore previous instructionsand the like) writegateway.content_flaggedevents where 2.2 wrote nothing. Nothing on the wire changes. -
The output length guardrail is replaced by a model’s maximum output tokens — check each model’s after upgrading. A cap of N bytes on the answer converts to
ceil(N / 4)output tokens, stored as converted. The answer is no longer measured or cut: a request asking for more tokens than the cap is lowered to it, in whichever field its API uses (a Chat request that sets bothmax_tokensandmax_completion_tokenshas both lowered), and the upstream stops there; one asking for less keeps its own. A request that sets no limit is held to the cap only when the cap is within what the gateway knows the model’s family to take (32,000 tokens for Claude models, 8,192 for others); a Chat request is then givenmax_completion_tokenson OpenAI’s own endpoint, whose reasoning models refusemax_tokens, andmax_tokenselsewhere. Above that figure, the request goes out without a limit rather than with one the model could refuse, and the model’s own default applies: a 100,000-byte cap on a non-Claude model converts to 25,000 tokens, so its requests that set no limit are not capped at all. To hold every request to a cap, have the clients send a limit, or set the cap to that family figure or below. Reasoning (thinking) tokens count towards the cap on the APIs that bill them as output, so a cap that fit an answer can cut short a model that thinks first. An Anthropic request with extended thinking has its thinking budget lowered below the cap too, or thinking turned off when the cap is 1,024 tokens or less, the smallest budget Anthropic takes. The model API’soutput_guardrailsfield is gone: a request that still sets one (anything butnullor[]) is refused with400, so a script cannot believe answers are still capped.max_output_tokens(1 to 2147483647,nullfor no limit) replaces it. -
A new installation observes by default. Every guard starts in observe mode, with only the built-in rules that rarely misfire switched on (personal data such as e-mail addresses and phone numbers ships off). Nothing is refused, replaced or deleted until a guard is switched to its third mode.
-
A content filter refusal is
403, with the error type of the caller’s API (permission_errorfor OpenAI-style APIs). Keyword and regex rules used to refuse with400. Ingateway_logs, itserror_typeisPolicyBlocked, where it wasTransformError. -
A rule that deletes text changes what is stored. The request a content rule stripped goes upstream, is redacted and is captured as the stripped one: the audit log’s request body is what was sent, not what the caller typed.
-
Changing a guard policy takes
settings:writeand the guard’s own permission:pii_redactor:writeforsecurity.redact,content_filter:writeforsecurity.contentandsecurity.inspect_tools, throughPATCH /api/admin/settings. 2.2 checkedsettings:writeon the server and the guard permission in the console; both are checked on the server now. Trying a sample takespii_redactor:readorcontent_filter:read. Reading the policies —GET /api/admin/security, which the console’s security page loads — takessettings:read. The seededadminandsuper_adminroles hold all of them. -
Console API changes.
GET /api/admin/securitylists each guard’s mode and every rule, andPOST /api/admin/security/{guard}/testtries a sample; they replace/api/admin/settings/content-filter/test,/content-filter/presets,/pii-redactor/test,/tool-inspection/rulesand/tool-inspection/test, which are gone. -
Audit events. Every rule that matches writes one event per request:
gateway.content_flagged,gateway.content_strippedandgateway.content_blocked;gateway.redaction_flaggedandgateway.redaction_replaced;gateway.tool_call_flaggedandgateway.tool_call_blockedas before.gateway.hidden_text_flaggedandgateway.hidden_text_blockedare gone; hidden characters are content events. No event carries the request’s text: a content event names the rule, the outcome, how many matches and whether they were in a tool result; a redaction event names the rule and counts the values and their occurrences, and only a built-in rule’s lists a few in masked form (sk-an…7f9c) — a custom rule’s values are not written at all. A request writes at most 20 events per guard (the rules that changed it, or matched most, first), each with the number of rules that matched (rules_in_request). Withaudit.body_redact_piion, captured bodies are redacted with the outbound redaction rules, built-in ones included, whatever the redaction mode. -
Metrics renamed.
gateway_hidden_text_totalis gone: hidden characters count ingateway_content_matched_total{outcome,custom}with every content rule.pii_pattern_invalid_total{pattern}is nowguard_policy_invalid_total{guard}(a stored policy with a rule that does not compile; the rule is left out), next toguard_policy_unreadable_total{guard}(a stored policy that cannot be read; the guard runs on its factory policy). Redaction counts values ingateway_redaction_found_total{kind,outcome}.
Added#
- Deleting what a content rule matches. A content rule can refuse the request, delete the matched text from the caller’s messages and tool results and send the rest, or only record. Text deleted joins back what it separated, so the request is checked again afterwards.
- Code point rules. A content rule can match characters by code
point (
U+200B,U+E0000–U+E007F), for invisible characters a keyword cannot be written for. - Every rule visible and switchable, built-in and custom, in each guard, with what it does in the third mode and what it did out of the box; a sample can be tried against one rule, an unsaved one, or all of them.
Changed#
- Core crates at ThinkWatch-Core v0.59.0.
tw-dialect,tw-guard,tw-breakerandtw-bedrockmove from v0.55.0; the shared guard model described above comes with them.
Fixed#
- A credential in a matched tool call no longer reaches the audit log. The excerpt of a tool call that inspection cut or recorded is masked with the redaction rules before it is written; 2.2 stored the matched arguments as they were, a key the model wrote in them included.
- A request’s audit events and its log row carry the same id when
the caller sends no
x-trace-id. The log row of a request that went through used to carry a second, unrelated id.
-
-
ThinkWatch Core v0.59.0 GitHub ↗
This release keeps a plugin’s configuration in the plugin’s own file. What a plugin does when it fails, which requests it handles and the values of its settings are written in the manifest of
plugins/<id>.js, which has to be plain data, andconfig.yamlkeeps only the plugin’s id, file, approved hash and whether it is on. Saving a plugin is one endpoint that tells a change of data from a change of code, and only plugins that can rewrite tool calls need a confirmation in the app. On Windows,twcore.exeno longer needs the Visual C++ Redistributable.Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) goes from 34 to 35. ThinkWatch Lite connects only to a core with the same protocol version. No ThinkWatch Lite release includes 0.58.0. ThinkWatch Lite 2026.10.0 includes 0.57.1 (protocol 31) and does not connect to 0.59.0. A server used with it stays on 0.57.1 until the app is updated to a release that includes 0.59.0;sudo twcore upgrade --version 0.57.1 --restartswitches a server back. - The request store’s schema is unchanged (25): upgrading from 0.58.0 keeps the request history. A server upgrading from 0.57.x also gets 0.58.0’s changes, so the 0.58.0 upgrade notes apply as well: the history is cleared once, and a configuration that still sets
security.hidden_textorsecurity.output_limitdoes not load. - A plugin’s manifest has to be plain data.
- The file declares it as
export const manifest = { … }. A manifest exported any other way, such asexport { manifest }, does not load. - It may hold objects, arrays, strings, numbers,
true,falseandnull. Keys are names or quoted strings. Trailing commas are fine, and so is a minus sign before a number. - Expressions, function calls, references to variables, spreads, computed keys, getters and methods, and template literals with
${…}are not data. Neither is a manifest that the module’s code changes after declaring it. - Such a plugin does not load.
gw.plugin.manifest_not_data_atgives the line and column;gw.plugin.manifest_not_datais used when there is no position.
- The file declares it as
- The manifest holds the plugin’s configuration.
on_error("reject", the default, or"skip") is a manifest field.matchis the scope the plugin runs with. It used to be a suggestion that filled in the configured scope at install.- Settings take
valuein place ofdefault: it is the value the plugin gets. A setting still written withdefaultdoes not load; rename the field tovalue. A setting withoutvaluegets its type’s empty value ("",0orfalse).
- Plugin entries in
config.yamlhold onlyid,file,sha256andenabled.- Entries written by 0.58.0 may still have
on_error,scopeandsettings. These are ignored whatever they contain, so the configuration still loads, and they are removed the next time core writes thepluginssection. - Their values are not carried over. To keep them, write them into the plugin’s manifest, or set them again in the app.
rewriteandconfirmedare now reserved: a configuration with a plugin of either id does not load.
- Entries written by 0.58.0 may still have
- Plugins installed by 0.58.0.
- The two default plugins are replaced with this release’s versions on first start and take this release’s setting values. One that was on is turned off, because this core cannot read what its 0.58.0 version asked for; turn it on again in the app.
- A plugin of your own whose manifest uses
default, or is not plain data, does not load until its file is fixed and the change approved. While it is on, it refuses the requests it covers:on_errordefaults toreject, and a 0.58.0 manifest cannot set it.
- Changed in the protocol:
- Removed:
PUT /plugins/{id}/source(ReplacePluginSource,PluginSourceReplace),UpdatePlugin,UpdatePluginConfirmed,PluginUpdate, andPluginView.settings(the values are insettings_schema[].value). PUT /plugins/{id}isSavePluginandPUT /plugins/{id}/confirmedisSavePluginConfirmed. Both takePluginSave:source,enabledandbase_version.- New:
POST /plugins/rewrite(PluginRewrite:PluginRewriteRequest→PluginSource),POST /plugins/confirmed(CreatePluginConfirmed) andPOST /plugins/{id}/approve/confirmed(ApprovePluginFileConfirmed). PluginCreatehas onlysource,id,enabledandbase_version.SettingSpecView.defaultis nowvalue, andManifestViewhason_error.PluginView.on_errorandPluginView.scopeare what the plugin’s file says.- A web view may call
CreatePlugin,SavePluginandApprovePluginFile. A client calls the three…Confirmedendpoints from native code after the user confirms, never from its web view.
- Removed:
- Confirmations. A plugin that can rewrite tool calls (the
reply.tool_callspermission) needs the user’s confirmation outside the web page to be installed, to be turned on, to have its code changed, or to have a change to its file approved. A plugin counts as one when its old or its new version has the permission, or when its old version cannot be read.- The plain endpoints refuse these with 403
control.plugin.needs_confirmation. - Nothing else needs a confirmation: changing only data, even of a plugin that can rewrite tool calls; turning a plugin off, deleting and reordering; and installing, turning on, changing or approving a plugin that cannot rewrite tool calls. In 0.58.0, every install, source replacement and file approval needed one, and so did changing the settings or scope of a plugin that can rewrite tool calls.
PUT /config,PATCH /configand rollback refuse to add, turn on or re-approve a plugin that can rewrite tool calls, with the same 403. They are open to the web view and used to bypass the rule. They have no confirmed variant: those changes go through the plugin endpoints.
- The plain endpoints refuse these with 403
- Message codes, compared with 0.58.0:
- New:
gw.plugin.manifest_not_dataandgw.plugin.manifest_not_data_at. - Removed:
config.plugin.blank_patternandconfig.plugin.setting_type. The configuration fields they checked are gone. control.plugin.needs_confirmationhas a new sentence that names the four actions. Its argument (plugin) is unchanged.
- New:
Plugin files hold their configuration. The app changes
on_error,matchor a setting by rewriting the plugin’s source and saving it.POST /plugins/rewritetakes a source and the new values and returns the rewritten source; it reads and writes no file.POST /plugins/inspectreads the values back from a source.- Only the manifest’s bytes change. Every other byte of the file stays as it was.
- Core writes the manifest in one fixed style: two-space indent, one manifest field per line, and nested objects and lists on one line when they fit in 80 columns. Comments inside the manifest are not kept.
- In a rewrite request,
on_errorandscopeare the values to end up with, andsettingschanges only the settings it names. Each scope entry is trimmed. A setting the plugin does not declare (gw.plugin.setting_unknown), a value of the wrong type (gw.plugin.setting_type) and an empty scope entry (control.plugin.blank_pattern) are refused with 400. - Core finds the manifest with a small tokenizer that skips strings, template literals, comments and regular expressions, so text that looks like a manifest inside them is not taken for it.
- A plugin whose file changed, or that does not load, follows the
on_errorandmatchof its approved file. The manifest is data, so core reads them even when the plugin cannot run.
Saving a plugin.
PUT /plugins/{id}takes the whole source and whether the plugin is on. Core compares the source with the approved one:- Data-only: the bytes outside the manifest are identical, and the manifest differs only in
on_error,matchand the settings’value. - Code change: anything else, including a change to the name, description, permissions, request kinds, or a setting’s type or label.
- Same bytes: only
enabledchanges, and the files are not touched.
When a save writes the file, the plugin file, its approved copy and its
sha256inconfig.yamlchange together, with no moment in between when the file counts as changed: a plugin that is on keeps running across the save. A save is tied to the configuration version it read, and fails with 409 when the configuration has moved on. A file edited by hand still stops the plugin until the change is approved, even when only a value changed.Default plugins.
reply-languageandwsl-pathswriteon_errorand their settings’valuein their manifests, in the style core writes, so changing a setting in the app changes one line of the file. The record of what core offered follows data-only saves and approvals, so a default whose settings, scope oron_errorwere changed still counts as unchanged code. A later version replaces it and keeps what was written in the file:on_error,match, and the values of the settings it still declares with the same type. A default whose code was changed is still left alone, and a new version that asks for more permissions or request kinds still comes back turned off.Windows.
twcore.exeno longer needs the Visual C++ Redistributable; the C runtime is linked statically. Thetwcore.exeof 0.58.0 importedVCRUNTIME140.dll, which comes with that package and not with Windows, so the gateway did not start on a machine without it.Requests full of secrets. Outbound redaction no longer slows down when one request holds many distinct values that look like secrets: finding, replacing and restoring them, and hiding them from plugins, now take time in proportion to the request’s size instead of growing with the square of the number of values. The security log records at most the first 100 distinct values of a request, and every value is still replaced.
- The control-plane protocol version (
-
ThinkWatch Core v0.58.0 GitHub ↗
This release adds script plugins: JavaScript that changes requests before they go upstream and answers before they reach the client, run in a WebAssembly sandbox inside core. It also reads a session back as a conversation, stores request and answer bodies with recognized secrets already replaced, and records an error that an upstream answered as a failed request. The five security guards become three. Hidden characters are now content-filter rules, the output limit is gone, and tool-call inspection gains two rules against sending credentials and files to other hosts.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) goes from 31 to 34. ThinkWatch Lite connects only to a core with the same protocol version. ThinkWatch Lite 2026.10.0 includes 0.57.1 (protocol 31) and does not connect to 0.58.0. A server used with it stays on 0.57.1 until the app is updated to a release that includes 0.58.0;sudo twcore upgrade --version 0.57.1 --restartswitches a server back. - The request store’s schema goes from 23 to 25: the security log has two new columns, and plugin runs have a table of their own. The request history is cleared when the store is rebuilt on first start.
security.hidden_textandsecurity.output_limitare removed. A configuration that still has either key does not load: the app starts in safe mode, andtwcore serveexits with the error.- Only configurations where one of these settings was changed are affected. A new configuration has no
securitysection, and the app writes only values that differ from the factory ones. In ThinkWatch Lite 2026.10.0, that means:- Hidden characters set to Off or Enforce (factory: Observe), or one of its two rules (Unicode tag characters, bidirectional controls) turned off.
- Output limit set to Observe or Enforce (factory: Off), or its limit changed from 100,000 characters.
- Before upgrading, do one of the following:
- set these back to their factory values in the app;
- delete
hidden_text:andoutput_limit:, with the lines indented under them, from thesecurity:section ofconfig.yaml.
- Hidden characters are now checked by content-filter rules that follow the content filter’s mode (see Three guards).
- Only configurations where one of these settings was changed are affected. A new configuration has no
- Changed in the protocol:
- Removed:
PUT /security/{guard}/limit(SetSecurityLimit,LimitSave);OutputLimitDetail,HiddenItem,HiddenKind;- the events
hidden_text_foundandoutput_limited; hidden_textandoutput_limitinGuard,SecurityDetail,SecurityViewandSecurityCounts.
GET /sessions/{id}/transcript(SessionTranscript→Transcript, withTranscriptTurn,TranscriptMessage,TranscriptRole,TranscriptPart,TranscriptGap).- The plugin endpoints:
GET /plugins(Plugins→PluginView),POST /plugins/inspect(PluginInspect:PluginSource→PluginInspection),POST /plugins(CreatePlugin,PluginCreate),PUT /plugins/order(ReorderPlugins,PluginOrder);PUT /plugins/{id}(UpdatePlugin,PluginUpdate),PUT /plugins/{id}/confirmed(UpdatePluginConfirmed),DELETE /plugins/{id}(DeletePlugin);PUT /plugins/{id}/source(ReplacePluginSource,PluginSourceReplace),GET /plugins/{id}/source(PluginSourceDiff→PluginSourceView),POST /plugins/{id}/approve(ApprovePluginFile,PluginApprove);POST /plugins/{id}/trial(TrialPlugin:PluginTrial→PluginTrialResult,TrialSide),GET /plugins/{id}/logs(PluginLogs→PluginLogEntry).- Supporting types:
ManifestView,PluginStatus,PluginStats,PluginLastError,PluginLoadError,PluginScope,PluginHooks,PluginRunView,SettingSpecView,SettingValue,Permission,RequestKind,OnError,ReplyMode,SettingKind,PluginHook,PluginOutcome,PluginLogLevel.
Event::PluginFailed.RequestDetailhaspluginsandrequest_after_plugins,HistoryRowhasplugin_changed, andConfigOriginhasdefaults.TurnViewhasstatus.- The security types are now defined in tw-guard and keep their names. Changes:
SecurityRuleViewandCustomRuleSavehavelabel.Matcherhasemail,cn-mobile-phoneandbuiltin;RuleActionhasstrip;ContentMatchhascodepoints.SecurityTestRequesthaslabelandaction;SecurityTestResulthasoutputandrefused.SecurityOutcomeandSecurityOutcomeCountshavestripped;SecurityEventViewhasmatchandrevealed.- The security counts in
/summaryaresecrets,secrets_replaced,tool_calls,tool_calls_cut,content,content_blockedandcontent_stripped.
content_matchedhasmatch,action,outcome(ContentOutcome:recorded,stripped,blocked),countandrevealed, and no longerblocked.
- Removed:
- Message codes, compared with 0.57.1:
- New:
- 52 for plugins (
config.plugin.*,control.plugin.*,gw.plugin.*).gw.plugin.reasononly passes on what the plugin wrote. gw.upstream.status_message.- Nine for the guards:
config.rule_codepoints_bad,config.rule_label_bad,gw.content.refused_invisible_message,gw.content.refused_invisible_tool_result,security.bad_codepoints,security.bad_label,security.content_action_unknown,security.pattern_empty,security.unknown_guard. - Two test-only codes, which never reach a UI.
- 52 for plugins (
- Renamed:
gw.toolcall.cut→gw.toolcall.response_cut,gw.toolcall.blocked→gw.toolcall.response_withheld,gw.ws.toolcall_cut→gw.toolcall.connection_cut(itsdetailargument is nowwhy). The sentences now say that the answer contained the call, not that an upstream returned it. - Removed:
config.output_limit_range,gw.hidden_text.refused_message,gw.hidden_text.refused_tool_result,gw.output_limit.cut,gw.output_limit.withheld,security.guard_unknown,security.limit_range,security.no_custom_rules,security.no_limit,security.nothing_to_test,security.unknown_content_action.security.unknown_guardandsecurity.content_action_unknowntake the place ofsecurity.guard_unknownandsecurity.unknown_content_action, with sentences that list the new guards and actions.
- New:
- Building from source needs an LLVM
clangthat can compile C to WebAssembly, and itsllvm-ar. The plugin sandbox compiles QuickJS while core builds. Apple’s clang cannot target WebAssembly; on macOS,brew install llvm. The README lists the other systems. The released binaries need nothing new. - Behavior worth knowing:
- Compaction requests (
/v1/responses/compact,/backend-api/codex/responses/compact) are screened by the content filter like generation requests. Token counts are still not screened. - Tool-call excerpts are masked before they reach the
tool_call_flaggedevent, the security log and system notifications. A secret restored from a placeholder used to appear there in the clear. - Stored bodies no longer contain the secrets and personal numbers that the redaction rules recognize, whatever the redaction mode (see Stored bodies).
- The retention settings now take effect. tw-store ran its own hourly cleanup with fixed limits (7 days of bodies, 90 days of rows, 2 GiB of bodies) beside the one that reads
retention, so any setting above those limits was cut back every hour. That cleanup is removed.retention.body_max_bytesnow defaults to 5 GiB (it was 2 GiB). - On first start, core adds its two default plugins to
config.yaml, turned off, and writes their files toplugins/beside it. That is one configuration version, recorded in the history asdefaults.
- Compaction requests (
Session transcripts.
GET /sessions/{id}/transcriptreads a session’s stored bodies back as a conversation. For every turn it gives the messages that are new in that request, the answer, and what could not be shown.- Clients resend the whole history on every turn, so a turn shows only what its request added after the previous readable one.
- Messages are compared by a fingerprint that ignores cache markers, reasoning signatures, key order and reasoning text. When the history was edited or compacted, the turn is marked
restartand carries the whole history. - It reads Anthropic Messages, OpenAI Chat Completions and Responses, Gemini and Bedrock, streamed and whole, including answers converted from another format.
- Tool-call ids in converted answers are matched to the ones the client recorded, so results pair with their calls.
- A message holds text, reasoning, tool calls, tool results, images (media type and size only), and other blocks by their type name. System and developer messages in the middle of a conversation appear as
systemmessages. - Gaps say what is missing:
request_missing,request_truncated,response_missing,response_truncated,response_unreadable. - Every string is masked after decoding. Image data and reasoning signatures are never returned.
- A sub-agent’s work is in its own session.
Stored bodies. Bodies used to be stored as the client sent them and masked only when read. They are now redacted when twcore hands them to the store, off the forwarding path.
- Every value the redaction rules recognize is taken out, whatever the mode,
offincluded.- Under
enforce, a request is stored with the placeholders the upstream received. - In the other modes, the values are masked.
- The whole text then goes through the same masking the read side applies.
- Under
- A value gets the same placeholder in every hop, in the stored request and in the stored answer.
- Up to 4 MiB of each answer is kept (it was 256 KiB), the same as for requests. A request over 4 MiB is stored cut, with its original length recorded, so a replay refuses it instead of sending half the JSON.
- Bodies waiting to be written are capped at 32 MiB as well as at 64 entries.
- Where the stored copy shows:
- The request detail shows the stored copy.
- Whole-history search no longer finds text that occurs only inside a secret.
- A replay sends the stored request, with placeholders or masked values where the secrets were.
- The fixture export redacts again with every built-in rule.
Failed upstream answers. When an upstream answered 4xx (or 3xx) and the gateway passed that answer on to the client, the request ended as finished with an empty error. It counted as successful everywhere. It now ends with
RequestFailed, from sourceupstream. A request failed if and only if itserroris set, in the traffic list, history search, the overview, sessions and the upstream check-up.- The recorded reason is what the upstream said:
gw.upstream.status_message(upstream,status,message), read in the upstream’s format, masked, and capped at 500 characters. An empty body or an HTML page givesgw.upstream.status. TurnView.statuscarries the upstream’s status code, so a failed turn can say what the upstream answered.- The client still gets the upstream’s answer byte for byte, and a client error still does not count against the upstream.
Three guards. Outbound redaction, tool-call inspection and the content filter now share one model in tw-guard: policy shape, built-in catalogs, validation, rule views, and the logic behind “Test…”. The enterprise edition uses the same model.
- Hidden characters are built-in content rules matched by code point.
unicode-tagsandbidi-controlsare on;zero-widthandprivate-useare off. - Actions. Content rules act with
block,strip(new) orrecordwhen the filter is inenforce. The hidden-character rules strip, where the old guard refused the request.- Stripped text is removed from user messages and tool results.
- The stripped body is what is redacted, recorded and sent on every hop.
- For tag characters, events and the security log also show the decoded text (
revealed).
- Custom rules. Custom content rules can match code points (
U+200B, U+E0000–U+E007F). Custom redaction rules can name their placeholder:label: PROJECTgives<<TW_PROJECT_1>>. - New redaction rules.
emailandcn-mobile-phone(Chinese mainland mobile numbers) are built in, off by default. - What is screened.
- Compaction requests are screened, and so are
response.createframes on the Responses WebSocket. Other WebSocket frames go through the code-point rules only. - Token counts, embeddings and legacy completions are not screened.
- A refused request still leaves a failed row.
- Compaction requests are screened, and so are
- Output limit. It is removed.
- Testing. “Test…” takes an action, and shows what would be sent and whether the request would be refused.
Two tool-call rules. Placeholders are restored in answers, so an upstream could write a tool call that sends a restored credential somewhere. Tool calls are judged as the client will execute them, after restoration. Two built-in rules cover this:
secret-to-unknown-host(high: cut inenforce). It fires when a tool call makes an http(s) request with an API key or private key in its arguments that the redaction rules recognize, and the destination is neither local nor that credential’s own provider (an Anthropic key going to anthropic.com is fine). JWTs and connection strings do not count here.upload-file-to-host(medium: recorded only). It fires when a tool call uploads a local file to a host that is not local:curl -T,--data @file,-F field=@file,--upload-file,--post-file.- The excerpt shows only
scheme://host, so it never carries the credential. - Tool-call inspection starts in
observe, where both rules only record. - The cut messages name the rule, not who produced the call, since a plugin can produce one too.
Script plugins. Plugins are JavaScript modules that change requests before they go upstream and answers before they reach the client.
-
Sandbox. They run only inside core, in QuickJS compiled to WebAssembly and run by Wasmtime. They have no file system, network, environment, timers or module imports. A request hook gets a fresh instance for every call. An answer gets one instance, shared by its hooks and discarded when the answer ends.
-
Permissions.
system,messages,tools,params,reply.textandreply.tool_calls. A plugin sees only the parts it was granted. Every edit is checked: keys, read-only fields and permissions. A request no plugin changed reaches the upstream byte for byte. -
Placeholders. Plugins never see a real secret. Before a hook runs, values the redaction rules recognize are replaced with placeholders, in every redaction mode and with the same numbering as outbound redaction. Placeholders are put back afterwards, whoever wrote them. Tool-call inspection judges the call after restoration.
-
Request hooks.
- They run after routing, once per upstream attempt, on the client’s request as the content filter left it.
- Failover to another upstream starts again from that request, so an edit made for one upstream never reaches the next. Resends to the same upstream reuse the result.
- A changed request is screened again. Only what the plugins added is reported, and only that can refuse the request.
reject(), an error underon_error: reject, or a refusal by the content filter refuses the whole request without failing over.- A plugin’s
params.modelrenames what that upstream gets and never re-routes. The gateway key’s model list still applies (gw.plugin.model_not_allowed).
-
Request kinds. A plugin handles the kinds of request its manifest lists in
requests, and conversations only when it lists none.- Conversations are Anthropic Messages, OpenAI Chat Completions and Responses, and Gemini, with their token counts and compaction.
- Embeddings and legacy completions go only through plugins that declare them, and only their input text can change.
- Other endpoints pass without any plugin.
-
Reply hooks. They run after format conversion and before tool-call inspection, so inspection sees what the plugin produced. They cover streamed, whole and converted answers, and the Responses WebSocket. Text comes in blocks or as a stream; tool calls come whole.
-
Management. The
plugins:section ofconfig.yamllists plugins in the order they run.- A plugin runs only while the SHA-256 of
plugins/<id>.jsmatches its approvedsha256. A changed file stops it within seconds, until it is approved again. - Each plugin has
on_error(rejectorskip), a scope (clients, models sent, upstreams) and settings.
- A plugin runs only while the SHA-256 of
-
Confirmations. These need the user’s confirmation outside the web page:
- installing a plugin, replacing its source, or approving a changed file;
- turning on a plugin that can rewrite tool calls, or changing its settings or scope.
UpdatePluginrefuses the second kind with 403control.plugin.needs_confirmation. A client callsCreatePlugin,ReplacePluginSource,ApprovePluginFileandUpdatePluginConfirmedfrom native code after the user confirms, never from its web view. ThinkWatch Lite asks in a system dialog. -
Default plugins. Two ship with core and are added turned off:
reply-languageasks the model to answer in a chosen language.wsl-pathsconverts drive paths in tool-call arguments between their WSL and Windows forms.
A default that was deleted is not added back, and one that was changed is left alone. A new version that asks for more permissions or request kinds comes back turned off. The sandbox (about 7 MB of memory) starts only once a plugin is turned on.
-
Limits.
- A request hook gets 200 ms of CPU and 128 MiB of memory. An answer gets 20 ms per call, 2 s in total and 64 MiB.
- Output is capped at twice the input plus 1 MiB, and logs at 100 lines per call (longer lines are cut at 4 KiB). The source is capped at 1 MiB.
- At most 32 plugin instances run on answers at once. Past that, a plugin follows its
on_error.
-
Recording. Every run is recorded with its hook, outcome, CPU time and upstream attempt.
- The request detail shows the runs and the body sent after plugins, which is stored with secrets replaced. History rows say when a plugin changed the request.
- Failures raise
plugin_failed. - Each plugin keeps its last 500 log lines in memory.
- A plugin can be tried on a recorded request without contacting an upstream.
Security fixes.
- rustls 0.23.45 fixes RUSTSEC-2026-0285: TLS 1.3 handshake messages were accepted across encryption-level boundaries. Every upstream request goes through rustls.
- The plugin sandbox ships with Wasmtime 49.0.2, which fixes RUSTSEC-2026-0325, -0326 and -0327. Those advisories were published against 49.0.1, which no release contained.
- CI now checks the dependencies against RustSec advisories.
- The control-plane protocol version (
-
ThinkWatch Lite v2026.10.0 GitHub ↗
Upgrade notes:
- The bundled core is now 0.57.1, which speaks control-plane protocol 31. A remote server has to run core 0.57.x too: this version does not connect to 0.56.x, and 2026.9.26 and earlier do not connect to 0.57.x.
- Request history is cleared on the first start, as the request store is rebuilt. Configuration, keys and upstreams are kept.
- Two redaction rules are on by default: Chinese resident ID numbers and bank card numbers. In Observe, the factory mode, they are only recorded; in Enforce they are replaced before a request leaves. Either can be switched off on the Security page.
- Routing rules on input tokens now use the gateway’s token-count estimate: one token per CJK character and 1,600 per image or file, with thinking left out. A request with many images can now cross a size threshold it used to stay under.
Searching the whole history:
- The Traffic page used to search only the latest 2,000 requests it had loaded. A search or filter now also runs over every stored request, newest first. The footer says how far back the search has got, and Search further goes back another page.
- The toggle inside the search box also searches content. It covers what each request newly sent (the last user turn, tool results included) and its answer (text and tool calls), for requests whose payloads are still kept.
- Each content match shows an excerpt under the request, with credentials masked.
Upstream check-up:
- Upstreams › Check-up compares each upstream with the others over 24 hours, 7 days, 30 days or a custom range:
- requests and failure rate;
- whether the model named in its answers matches the one sent, with the pairs that differ;
- the input it reports, as a multiple of the local estimate, against other upstreams serving the same model;
- the share of input read from the prompt cache in follow-up turns, against those upstreams too;
- median time to first token and generation speed.
- Every figure comes with its sample size. A deviation is marked only when both sides have enough samples:
- input: a gap of 25% or more, and only on the upstream further from the local estimate;
- cache: other upstreams read at least 30% from the cache and this one less than half of that.
ID and bank card numbers:
- Outbound redaction now also replaces Chinese resident ID numbers and bank card numbers. A number counts only when its structure and check digit are valid:
- an ID number needs a region code, a real birth date and a valid check character;
- a card number needs the prefix and length of UnionPay, Visa, Mastercard, American Express, JCB, Discover or Diners Club, and must pass the Luhn check.
- Placeholders say what was there, such as
<<TW_ID_NUMBER_1>>, and the security log shows only the last four characters.
More clients:
- The Clients page now connects Pi, oh-my-pi, Grok Build, Qwen Code and Hermes Agent:
- Pi and oh-my-pi get a provider of their own with the models the key can use. Their default model stays as it is, and ThinkWatch models are chosen in
/model. - Grok Build gets one model entry per gateway model, each with its own key, so the xAI session token is never sent to the gateway. One of them becomes the default model. Remote campaigns, which can change the default model, are turned off while it is connected.
- Qwen Code 0.19.3 or later gets a provider of its own in the OpenAI format, and the change applies after a restart.
- Hermes Agent: its default profile is pointed at the gateway, and other profiles keep their own configuration.
- Pi and oh-my-pi get a provider of their own with the models the key can use. Their default model stays as it is, and ThinkWatch models are chosen in
- DeepSeek Harness counts as installed when only its desktop app is. Models used through a DeepSeek account in the desktop app still go to DeepSeek directly, which the plan now says.
- The MCP page lists the servers of the five new clients without writing to them. Skills in the shared
~/.agents/skillsfolder appear under a shared folder, and Cursor’s hooks are scanned too. - Codex stopped reading
OPENAI_BASE_URLin April 2026, so a shell that still exports it is no longer reported as overriding the connection.
Getting started:
- Until the first request reaches an upstream, the Overview page shows a checklist: add an upstream, connect a client, send a first request.
- Other pages show a hint for the next step where it applies. A hint can be hidden, and Settings › General shows the hidden ones again.
Requests other than POST:
- These now get a 404 from the gateway itself. Hermes Agent sends such GETs when it starts, to tell whether a server is Ollama or LM Studio.
- They used to be sent to an upstream as empty POSTs and recorded as failed requests.
The Features page describes each of these in full.
-
ThinkWatch Core v0.57.1 GitHub ↗
This release answers requests other than POST in the gateway itself. Before, they were forwarded to an upstream and recorded as failed requests, so a client checking whether the gateway is a local model server sent those checks on to the upstreams.
Upgrade notes
- The control-plane protocol version is unchanged (31) and so is the request store’s schema (23). ThinkWatch Lite 2026.9.26 includes 0.56.0 (protocol 30) and does not connect to 0.57.x: a server used with it stays on 0.56.0 until the app is updated to a release that includes 0.57.1.
- One message code is added:
gw.method.not_forwarded. - A GET, HEAD, OPTIONS, PUT or DELETE to a path the gateway does not serve itself now gets a 404 from the gateway instead of whatever the upstream answered to an empty POST.
Requests other than POST. Every upstream API the gateway forwards to takes POST, and the gateway always sent the upstream request as POST. Any other method that reached the catch-all route was sent on as an empty POST, recorded as a failed request and, with failover, tried on the next upstreams.
- Hermes Agent sends five such GETs when it starts (
/api/v1/models,/api/tags,/v1/props,/props,/version) to tell whether a server is LM Studio, Ollama, llama.cpp or vLLM. - An upstream that answered one of them with 200 made Hermes take the gateway for that kind of server.
These requests now get a 404 in the client’s error format, after the key check, and are neither forwarded nor recorded. A request without a key still gets 401.
/v1/models,/v1beta/models,/api/hello,/healthzand WebSocket upgrades are unchanged. -
ThinkWatch Core v0.57.0 GitHub ↗
This release redacts Chinese resident ID numbers and bank card numbers out of the box, searches the whole request history including the text of requests and answers, and adds an upstream check-up: whether the model named in an upstream’s answers matches the one sent, how its reported input compares with other upstreams serving the same model, how much of its input comes from the prompt cache, and its time to first token and speed.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) is now 31. ThinkWatch Lite connects only to a core with the same protocol version, and has to regenerate its protocol types from this release to use it. A server used with ThinkWatch Lite 2026.9.26 or earlier (protocol 30) stays on 0.56.0 until the app is updated to a release that includes 0.57.0;sudo twcore upgrade --version 0.56.0 --restartswitches a server back. - The request store’s schema goes from 22 to 23 (three new columns): the request history is cleared when the store is rebuilt on first start.
- Two redaction rules are on by default:
cn-resident-idandbank-card(see below). With outbound redaction inenforce, such numbers in a request are replaced before it leaves; inobservethey are recorded.security.redact.disableturns either off. - Routing estimates input size the way token counting does. Rules on
input_tokensnow see one token per CJK character, 1,600 per image or file, and no thinking, instead of four bytes per token with images and files left out. A request with many images can now cross a size threshold it used to stay under. - Changed in the protocol:
POST /history/search(HistorySearchQuery→HistorySearchPage, withHistoryCursor,ContentHit,ContentSide,SearchStop).GET /upstreams/health(UpstreamHealth,UpstreamCheckup,ModelConsistency,ModelPair,InputVsEstimate,InputForModel,RatioView,CacheReads,CacheForModel,CacheTally,MedianView).Event::RequestStartedhasinput_estimate;RequestFinished,RequestFailedandRequestCancelledhaveanswered_model(both left out when absent).Matcherhascn-resident-id(born_since) andbank-card(networks:CardNetwork,CardPrefix);SecretKindhaspersonal.
- No message codes change.
Chinese resident ID numbers and bank card numbers. Outbound redaction looked only for credentials. Two built-in rules now look for personal numbers, held to the same bar as the credential rules: a value must check out structurally, not merely look like one.
cn-resident-id: an 18-character resident ID number whose first two digits are a province-level code, whose birth date is a real date between 1900-01-01 and today, and whose check character (ISO 7064 MOD 11-2) is right. The old 15-digit numbers are not matched.bank-card: a number whose prefix and length belong to UnionPay, Visa, Mastercard, American Express, JCB, Discover or Diners Club and which passes the Luhn check, written in one run or in groups of four separated by single spaces or hyphens (and American Express’s 4-6-5, Diners Club’s 4-6-4). The public test card numbers of Stripe, Braintree and Adyen are left alone.- A number inside a longer run of letters or digits is not matched, and neither is a number written as a JSON number (a tool call’s arguments, for example): replacing it would leave the body invalid JSON.
- Placeholders say what was there:
<<TW_ID_NUMBER_1>>,<<TW_CARD_NUMBER_1>>. They are restored in the answer as before, streams included. The security log shows only the last four characters. - Scanning a request body also got faster: the token scan no longer allocates per token.
Searching the whole history. The Traffic page searched only the requests it had loaded.
POST /history/searchapplies the same filter to every stored request, in pages that go back in time.- It matches the free text against the path, client, client hint, peer, upstream, model and error message, and takes the message codes whose translation matched, so a query in the app’s own language finds errors stored in English.
- With
content, it also searches, for requests whose bodies are still kept, what is new in each request: the last user turn (text and tool results) and the answer (text, and each tool call’s name and input). Clients that resend the whole conversation every turn therefore do not match every later request of a session. Each match comes with a short excerpt, with credentials masked. - Content search reads at most 500 requests’ bodies or 1.5 seconds per call, newest first, and says how far back it got, so a client can offer to search further.
Upstream check-up.
GET /upstreams/healthreports, for every upstream over a window (the last 7 days by default), facts and sample sizes rather than verdicts:- requests, failures and cancellations;
- whether the model named in its answers matches the model sent (after rule rewrites), with up to three example pairs. Names are compared after removing differences that are not a different model (vendor prefixes, dates and snapshot markers, Bedrock’s inference-profile and version forms,
4.5against4-5), never the family, size, generation or variant; - the median ratio of the input it reported to the gateway’s local estimate, overall and, for every model that other upstreams also served, next to the ratio of those other upstreams;
- for later turns of a conversation sent to it within five minutes of the previous one, the share of input read from the prompt cache and how many such turns read none, also next to other upstreams serving the same model;
- median time to first token and generation speed.
- The answered model is read from the upstream’s own response in the same pass that reads usage (Bedrock’s responses do not name one). The estimate is the one routing already computes.
- The control-plane protocol version (
September 2026
-
ThinkWatch Enterprise v2.2.0 GitHub ↗
This release fixes authorization. The gateways never checked
ai_gateway:useormcp_gateway:use, so a user with no role, or with only roles that grant neither, could call every model and MCP tool; a role granted at the scope of one team widened model and tool access platform-wide; and an API key whose allow-list came out empty could call any model. The gateways now require the permission and count only the roles that grant it. The seededteam_managerrole now works when granted at team scope, and seven permissions that nothing ever checked are retired. Some users lose gateway access on upgrade: read the first section before deploying. There is one manual database script and no schema, setting, environment variable or Helm value change.Read before upgrading#
-
Users without a role that grants gateway use lose gateway access. A request to the AI gateway is refused with
403unless a role held by the key’s owner grantsai_gateway:use, and a request to the MCP gateway unless one grantsmcp_gateway:use. The body names the missing permission, in the error format of the caller’s API. The built-indeveloper,admin,super_adminandteam_managerroles grant both;viewergrants neither. Three groups of users are refused after the upgrade where 2.1.0 let them through:- users with no role at all;
- users whose roles are only
viewer, or custom roles without these actions; - users whose only gateway role is assigned at team scope (see the third item below).
Find them before upgrading. This query lists every active user with a live key for a gateway that none of their roles will grant (it counts global assignments and roles attached to the user’s teams, which is what the gateways now read; it does not account for
Denystatements):WITH grants AS ( SELECT id AS role_id, jsonb_path_exists(policy_document, '$.Statement[*] ? (@.Effect == "Allow").Action[*] ? (@ == "*" || @ == "ai_gateway:*" || @ == "ai_gateway:use")') AS ai, jsonb_path_exists(policy_document, '$.Statement[*] ? (@.Effect == "Allow").Action[*] ? (@ == "*" || @ == "mcp_gateway:*" || @ == "mcp_gateway:use")') AS mcp FROM rbac_roles ), held AS ( SELECT user_id, role_id FROM rbac_role_assignments WHERE scope_kind = 'global' UNION SELECT tm.user_id, tra.role_id FROM team_members tm JOIN team_role_assignments tra USING (team_id) ), access AS ( SELECT h.user_id, bool_or(g.ai) AS ai, bool_or(g.mcp) AS mcp FROM held h JOIN grants g USING (role_id) GROUP BY h.user_id ) SELECT u.email, s.surface FROM api_keys k JOIN users u ON u.id = k.user_id CROSS JOIN LATERAL unnest(k.surfaces) AS s(surface) LEFT JOIN access a ON a.user_id = u.id WHERE k.is_active AND k.deleted_at IS NULL AND u.is_active AND u.deleted_at IS NULL AND NOT COALESCE(CASE s.surface WHEN 'ai_gateway' THEN a.ai WHEN 'mcp_gateway' THEN a.mcp END, false) GROUP BY u.email, s.surface ORDER BY u.email, s.surface;Give each of them
developer(or a custom role with the actions) either at global scope (Users → edit the user’s roles, scope Global), or by attaching the role to a team they belong to (Teams → the team → Roles), which every member of that team inherits. The change takes effect within a minute; each user’s permissions are cached for 60 seconds.If SSO users are meant to use the gateway from their first sign-in, set Default Role for New Users (
auth.default_role) in Settings todeveloper. It is empty by default, and it applies only to accounts created after it is set; existing users need the grant above. -
A role that does not grant gateway use no longer widens model or tool access. Model and MCP tool scopes are now the union over the roles that grant
ai_gateway:use/mcp_gateway:useonly. A role without those actions (such asviewer) used to count as “unrestricted”, so adding it to a user limited to some models opened every model to them. Users relying on that lose the extra models. -
A role assigned at team scope no longer grants gateway access. An assignment with scope
team:<id>administers that team from the console (team roster, team limits); it no longer contributes models, MCP tools or gateway use, which it used to do for every request the user made, member of the team or not. Roles attached to a team itself (Teams → Roles), which every member inherits, still count. A user whose only gateway role was a team-scopeddeveloperorteam_managerneeds that role at global scope, or attached to their team. The role assignment editor now says this under the scope picker. -
An empty model allow-list allows nothing. A key whose
allowed_modelsis[], or whose list shares no model with what its owner’s roles grant, used to call any model; it now calls none, asallowed_mcp_tools: []already did on the MCP gateway. The console never saves[](clearing the picker sendsnull), so only keys written through the API are affected. To find them:SELECT id, name, user_id FROM api_keys WHERE allowed_models = '{}' AND is_active AND deleted_at IS NULL;Set such a key’s list tonullto leave it bounded by its owner’s roles only. Model entries still match by prefix, and a key narrowed togpt-4o-miniunder a role grantinggpt-4okeepsgpt-4o-mini. -
Run
db/release_migrations/2026-09-30_retire_unchecked_permissions.sqlonce, after deploying. The server does not apply it. It addsteams:readto theteam_managerrole and removes the seven retired permissions (see Changed) from every role, system and custom. It is idempotent and changes no access for any other role, since nothing checked the removed keys. Without it, the server still starts and works, but:team_managerkeeps the oldteam:readit cannot use, so a team manager who is not a member of the team they manage still gets403opening it, its roster or its roles;- every start logs a warning listing the roles that still name retired permissions.
Reset to defaults on a system role in the console has the same effect for that one role, but does not clean custom roles.
-
A team-scoped
rate_limits:writenow takes effect. It used to require global scope for every subject, so the seededteam_managergranted at team scope could not touch any limit. It now covers the rate limits and budget caps of users in that team and of the API keys they own, never the holder’s own user or keys; role subjects still need global scope. Anyone holdingteam_manager(or a custom role withrate_limits:write) at team scope can now change, lift or delete the limits of every member of that team, administrators included. Review team-scoped assignments of these roles before upgrading.
Security#
- Gateway use is checked, and a missing grant means no access. See
the first three items above: a user with no role, or only roles
without gateway use, could call every model and MCP tool; a
viewer-style role widened a restricted user to every model; a role granted at the scope of any team, even one the user was not a member of, widened model and tool access platform-wide. An explicitDenyonai_gateway:useormcp_gateway:usenow closes that gateway. Action wildcards (ai_gateway:*,*) grant it, as they already did for console permissions. - A key narrowed inside a prefix grant is no longer unrestricted.
The key’s allow-list was intersected with its owner’s role grants
entry by entry, as literal strings. A key limited to
gpt-4o-miniunder a role grantinggpt-4o(a prefix) came out with an empty list, which the gateway read as “no restriction”, so the key could call every model. The intersection now keeps an entry of either side that the other side covers, by prefix for models and by<server>__*pattern for MCP tools, and an empty result allows nothing.
Fixed#
- The seeded
team_managerrole works at team scope. It grantedteam:read, but the team handlers checkteams:read, so a team manager got403listing teams and opening the team, its roster or its roles. It now grantsteams:read, and opening a team acceptsteams:readscoped to that team, where it used to require global scope. Existing installations need the release migration above. - Bulk disable and delete work on API keys’ limits. The bulk
disable and delete routes for rate-limit rules and budget caps
rejected every row stored for an API key (
api_key_lineage, the kind a key’s limits are stored under) as an unknown subject kind. - The console’s effective-permissions preview shows real gateway access. It now counts only roles that grant gateway use and skips team-scoped assignments, as the gateways do, where it used to count every assigned role.
Changed#
- Seven permissions that nothing checked are retired:
team:read,team:write,logs:read_own,logs:read_team,audit_logs:read_own,audit_logs:read_teamandaudit_logs:read_all. Every log endpoint, audit logs included, is gated onlogs:read_allat global scope, and no own- or team-filtered log view exists, so these grants never did anything. They are gone from the permission catalog, the role editor and the seeded roles. Roles that still name them load, and the server logs a warning at start instead of refusing to boot. - Deleting one rate-limit rule or budget cap is bound to the subject
in the path.
DELETEon/api/admin/limits/{kind}/{id}/rules/{rule_id}and/api/admin/limits/{kind}/{id}/budgets/{cap_id}now answers404when the rule or cap belongs to a different subject; it used to delete any row id once the path’s subject was authorized. - Creating or editing a key with an explicit allow-list checks it
against gateway-granting roles only. An
allowed_modelsorallowed_mcp_toolsentry is refused with400when no role of the owner that grants the gateway covers it, so a user without a gateway role can no longer save a non-empty list.
-
-
ThinkWatch Enterprise v2.1.0 GitHub ↗
Amazon Bedrock becomes a provider you can run from the console. It authenticates with a Bedrock API key as well as access keys or the instance role, lists its models for import and for the route editor, and has a working Test Connection. Requests converted for Bedrock now keep their prompt-cache breakpoints and Claude’s thinking settings. The route editor takes any upstream model name, and a route the provider refused can be created anyway. ThinkWatch-Core moves to v0.55.0, whose Bedrock layer this edition now shares with the desktop gateway. No database, setting, environment variable or Helm value changes.
Read before upgrading#
- Test Connection with a saved provider’s secrets needs
providers:update. A test that names a saved provider (provider_id, which is what the Edit dialog sends) used to take onlyproviders:create. A custom role that hasproviders:createwithoutproviders:updatecan no longer test existing providers; the built-in admin role has both. A test with values typed into the request still takesproviders:create. See Security below. - Refusing a route the provider does not serve answers
model_not_served.POST /api/admin/models/{model_id}/routesstill answers400when the import probe found no API the provider serves the model on, buterror.typeis nowmodel_not_served, where it wasbad_request. Scripts that matched onbad_requestfor this case need the new value. The request takes a new optional"force": trueto create the route anyway. error_typeingateway_logsfor failed requests is always the error’s tag. An upstream HTTP error used to log its debug text, such asProviderHttpError { status: 502, message: "…" }, and an upstream rate limit a cut-offUpstreamRateLimited { retry_after_secs: Some. They now logProviderHttpErrorandUpstreamRateLimited, as the metric labels and streamed requests already did. Dashboards or log forwarder queries that grouped by those strings see them merge into one value each.- The server log now carries the upstream’s reply to a 401 or 403,
as it already did for other upstream errors. For Bedrock that reply
names the AWS account and the IAM principal. Callers still see only
“Authentication failed with upstream”, and failover, breakers and
gateway_logstreat these errors as before. - Prompt caching now works on Bedrock routes, and is billed at cache
prices. Requests converted for Bedrock used to lose their
cache_controlbreakpoints, so Claude on Bedrock re-read the whole prompt at full price every turn. Breakpoints now go out as ConversecachePointblocks, to Claude and Nova models only (others reject them), and the cache reads and writes Bedrock reports come back into usage, one-hour writes included. Traffic with repeated prompts, such as Claude Code, costs much less on Bedrock routes than it did; writes cost a little more, atcache_write_weight/cache_write_1h_weight. - Claude’s thinking reaches Bedrock. A request converted for Claude
on Bedrock now carries its thinking and effort settings, in the form
Anthropic’s API uses, along with the sampling limits thinking imposes
(
temperature1, notop_k,top_pat least 0.95). Thinking used to be dropped on the way to Bedrock. Other Bedrock models still get no thinking.
Added#
- Bedrock providers can authenticate with a Bedrock API key. Pick
Bedrock API Key as the authentication mode and paste the key AWS
generated. It is kept like any other provider’s API key, as an
encrypted
Authorization: Bearerheader, and replaced in the provider’s Edit dialog. A Bedrock provider that sends anAuthorizationheader is not SigV4-signed; one without it is signed as before, with access keys or the instance role. Use a long-term key: a short-term one expires within 12 hours. - Bedrock models can be imported, and are suggested in the route
editor. A Bedrock provider now lists what it can be routed to: the
region’s foundation models that can be invoked on demand and answer in
text, and the inference profiles AWS defines, such as
us.anthropic.claude-sonnet-4-5-20250929-v1:0. A model that is only served through an inference profile, as most current models are, is listed under its profiles’ ids and not its own. Whether the account may call a model is still checked one model at a time when it is imported. The provider’s credential needsbedrock:ListFoundationModelsandbedrock:ListInferenceProfiles; theAmazonBedrockLimitedAccesspolicy a long-term API key is created with allows both. - Test Connection for Bedrock providers, however they authenticate: with an API key, access keys or the instance role. It lists the models above, so a wrong credential or a missing permission shows up before any traffic does.
- Create anyway, for a route the provider refused. When the route
editor’s save is refused because the provider does not serve the
model, the error now offers Create anyway: the refusal can be
stale, or be about the probe’s request rather than the model. The
route is created, and its
model_route.createdaudit row records the refusal it overrode in a newrefusal_overriddenkey.
Changed#
- The route editor’s upstream model field takes any model name. For a provider that lists its models, the field used to turn into a list to pick from, so a model the listing leaves out could not be routed to from the console. It now suggests the provider’s models as you type, and takes whatever is typed.
- Bedrock instance-role credentials are cached. They took three IMDSv2 round trips per request; they are now kept until five minutes before they expire, and a burst of requests at expiry makes one trip.
- Chat Completions requests can switch reasoning with
thinking. When a request is converted for another format,thinking.type(enabled/disabled, as DeepSeek, GLM and Kimi write it) now turns reasoning on or off;disabledwins overreasoning_effort. - Core crates at ThinkWatch-Core v0.55.0.
tw-dialect,tw-guardandtw-breakermove from v0.43.0, andtw-bedrockjoins them: SigV4 signing, eventstream unframing, Bedrock’s addresses, region checks and model catalog now come from core. Where credentials come from (provider keys, the instance role) stays in this repository.tw-breakeris unchanged.
Fixed#
- Bedrock providers could not be created or edited in the console.
The region was checked as a URL, so saving failed with
400 Invalid URL. A Bedrock provider’sbase_urlis now checked as an AWS region such asus-east-1, and anything else is refused, since the host is built from it. A provider saved with a URL there never reached Bedrock; set its region in the Edit dialog. - Bedrock models the account may not call were imported anyway. Bedrock refuses such a model (model access not granted, or an IAM or organization policy that denies it) with a 403, and the import probe read every 403 as a credential problem that says nothing about the model. The route was created and failed on first use. Now, when the region’s control plane accepts the same credential, the refusal is recorded as the model’s, with AWS’s reason, and the model is skipped on import like any other refused one. Once access is granted, re-check the provider’s models.
- A Bedrock provider whose stored secret key would not decrypt signed
with an empty secret, and every request failed with AWS’s
SignatureDoesNotMatch. It now refuses its requests with the reason until the keys are saved again, rather than falling back to the instance role, which would call AWS as a different identity. An access key ID saved without a secret is refused the same way. - A Bedrock model id that is an ARN could not be routed. Its
/went into the request path unescaped, adding a path segment AWS could not route. It is now escaped. - A base URL ending in its version segment doubled it. A provider
written as
https://api.openai.com/v1sent requests, and the model listing, to/v1/v1/…and got 404s. A trailing version segment (v1,v1beta, …) that the request path starts with is now written once. Base URLs without one are unchanged. - Typing into a provider’s API key field and clearing it again wiped
the saved key on save. The field sent
Bearerwith nothing after it. A cleared field now keeps the saved key, as a blank one always did.
Security#
- Testing a connection with a saved provider’s secrets takes
providers:update. The test fills each header left blank with the saved provider’s secret and sends it to the URL in the request, andproviders:createwas enough to ask for it. So a user allowed only to create providers could send any saved API key to a server of their own. A test that names a saved provider (provider_id) now takesproviders:update, which lets a user point that provider elsewhere anyway; a test without one still takesproviders:create. A user withproviders:updatealone can now use Test Connection in the Edit dialog, which used to answer403.
- Test Connection with a saved provider’s secrets needs
-
ThinkWatch Lite v2026.9.26 GitHub ↗
Upgrade notes:
- The bundled core is still 0.56.0 (control-plane protocol 30), so a remote server on 0.56.0 keeps working.
- Configuration and request history are kept.
MCP page: the servers table now has a column only for the clients found on this computer: those whose own directory or MCP file exists, or whose MCP file was chosen by hand. It used to draw all eight supported clients, which did not fit the window. The clients that were not detected are listed in one line under the table, where Choose location… points the app at a client installed somewhere else.
Toolbar: the page name is always shown in the window toolbar, next to the sidebar toggle, instead of at the top of each page.
-
ThinkWatch Lite v2026.9.25 GitHub ↗
Upgrade notes:
- The bundled core is now 0.56.0, which speaks control-plane protocol 30. A remote server has to run core 0.56.0 too: this version does not connect to 0.55.x, and 2026.9.24 and earlier do not connect to 0.56.0.
- Request history is kept.
- Strategy groups no longer have the sticky-session switch. A configuration in which it was turned off contains
session_affinity: falseunder that group; core 0.56.0 refuses such a configuration and the app starts in safe mode, naming the line. Delete the line.
Failover:
- An upstream can answer, open a stream and then report an error before sending anything, such as Anthropic’s
overloaded_erroror aresponse.failedwhen a ChatGPT account’s usage limit is reached. The client used to receive that error. A streamed answer is now held until its first content arrives, and an error before that point sends the request to the next upstream of the route. What was received while waiting is passed on unchanged. - Upstreams that reject the credential (401, 403), report no balance (402) or do not have the model (404) now send the request to the next upstream as well. A 400 or 422 does so only when the upstream says the balance or quota is used up or the model is unavailable; other client errors still go straight back. When no upstream is left, the last upstream’s own error reaches the client.
- How long a failing upstream is paused depends on the reason it gives: an insufficient balance pauses it for 30 minutes, a used-up quota until the reset time the upstream names (1 hour when it names none), a rate limit for the wait it asks for (at most 1 hour). Failures without a stated reason pause it after 3 in a row, for 1 minute, doubling with each further pause up to 10 minutes.
- Settings › Failover sets all of these, including how long to wait for a streamed answer’s first content (15 seconds).
Prompt cache across a conversation:
- A conversation now stays with the upstream that answered it, including one that took over after a failover: always within a turn, and in later turns while its prompt cache is still warm. Load-balance groups spread only new conversations.
- The routing decision made at the start of a turn holds until the turn ends, so a rule on input size or images no longer switches upstreams halfway through an agent’s work.
- The request details’ Routing tab says when a request kept its turn’s route or stayed with the upstream that answered before.
Import links: a relay or vendor can hand out a link,
thinkwatch://import?…orhttps://thinkwat.ch/import#…, that pre-fills a new upstream with a name, base URL, protocol, API key and model list. The app shows the settings and the host that will receive requests and the key, and writes nothing and contacts nothing until Create is chosen. A link only adds one upstream: it cannot change existing upstreams, headers, proxies, pricing or routes, and a key that refers to an environment variable is rejected. The parameters and a link builder are in Import links.Token counts: Claude Code asks for a token count on every turn, which only an upstream in Anthropic’s format can answer. Routed to an upstream of another format, or to a relay that does not implement it, the count used to fail; the gateway now answers with its own estimate, sends nothing to that upstream, and shows it in the attempt chain as estimated locally.
Reasoning after a switch of account: reasoning that one account produced is sealed for that account, and another account or key refuses a request that carries it back. After a failover between accounts such a request is now sent again without it, and later turns of the conversation leave it out from the start.
-
ThinkWatch Core v0.56.0 GitHub ↗
This release keeps a conversation on the upstream that holds its prompt cache and decides a turn’s route once, moves a request to the next upstream when a stream fails before its first content, sets an upstream aside for as long as the reason it gives calls for, answers token counts that no upstream can serve with a local estimate, and resends a request whose reasoning another account sealed.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) is now 30. ThinkWatch Lite connects only to a core with the same protocol version, and has to regenerate its protocol types from this release to use it. ThinkWatch Lite 2026.9.24 includes 0.55.2 (protocol 28) and does not connect to 0.56.0: a server used with it stays on 0.55.2 until the app is updated to a release that includes 0.56.0.sudo twcore upgrade --version 0.55.2 --restartswitches a server back to 0.55.2. - The request store’s schema is unchanged (22): the request history is kept.
groups[].session_affinityis removed. A configuration that still sets it (onlysession_affinity: falsewas ever written) is refused; delete the line.- Changed in the protocol:
- Removed:
GroupView.session_affinity,GroupView.hurts_cache,GroupInput.session_affinity,DryRunResult.hurts_cache. RequestRouted.affinityandRoutingView.affinity(AffinityView,Stay): whether the request kept its turn’s route and why it stayed with the upstream that answered before.Overview.failover(FailoverView).AttemptOutcomehasestimated.
- Removed:
- New in the configuration: the
failoversection (see Setting a failing upstream aside). - Message codes: new
config.failover_rangeandgw.upstream.stream_opening_error; removedgw.count_tokens.bedrock_model.
A conversation stays where its cache is. Routing rules used to be evaluated for every request, and only
load-balancegroups kept a session on one upstream. A rule oninput_tokensor on images could therefore switch upstreams in the middle of an agent’s turn, and a failover moved the conversation away from the upstream holding its prompt cache for good.- A conversation is recognised by the client’s session header (
x-claude-code-session-id,session_id,conversation_idand a few others) together with the content fingerprint, or by the fingerprint alone. A turn lasts from a user message until the next one; tool results belong to the turn. - The routing decision made at the start of a turn (rule, group, rewrites) holds for the rest of the turn. It is made again when the input approaches the smallest context window among the candidate models, where the price data gives one.
- The next request goes first to the upstream that last answered the conversation, including one that took over after a failover: always within a turn, and across turns while its last answer read or wrote at least 1024 cached tokens less than five minutes ago. An upstream that is set aside, not among the candidates or in another group is not held to.
load-balancetakes turns between new conversations only.session_affinityand the warnings that a group lowers the cache hit rate are gone, since no strategy drops a warm cache in the middle of a conversation any more.
Errors at the start of a stream fail over. An upstream can answer 200, open a stream and report an error as its first event: Anthropic’s
overloaded_error, the Codex backend’sresponse.failedwhen the usage limit is reached, Bedrock’sthrottlingException, an error chunk from a Chat or Gemini upstream. The client had received nothing yet, and the error still reached it. A streamed answer is now held until its first content arrives, and an error before that point moves the request to the next candidate, as an error status does. What was read while waiting is passed on unchanged. The wait ends afterfailover.stream_start_wait_secs(15) or 1 MiB; the last candidate is not held.Setting a failing upstream aside. Failures are sorted by the reason the upstream gives.
- 401, 403, 402 and 404 now move the request to the next candidate. A 400 or 422 does so only when its body names an insufficient balance, a used-up quota or an unavailable model; other 4xx still go to the client unchanged. The last candidate’s own 4xx reaches the client as the upstream wrote it.
- An insufficient balance sets the upstream aside for
no_balance_pause_secs(1800). A used-up quota sets it aside until the reset time the upstream names in the body, in its quota headers or in a GLM 429, and forquota_pause_secs(3600) when it names none. A rate limit withRetry-After(or Gemini’sretryDelay) sets it aside for that long, at mostrate_limit_max_pause_secs(3600). A missing model moves on without counting against the upstream. - Failures without a stated reason pause the upstream after
failures_to_pause(3) in a row, forpause_secs(60), doubling with each further pause up tomax_pause_secs(600) and starting over after a success. - A request with a single candidate is never affected, and when every candidate is set aside they are all tried.
Counting tokens.
/v1/messages/count_tokensand Gemini’s:countTokensrouted to an upstream of another format, or to a same-format upstream that answers 404 or 405, are answered by the gateway with an estimate of the system prompt, messages, tool calls and results, and tool definitions; nothing is sent to that upstream. The answer carriesx-thinkwatch-local: 1, and the request is recorded as a local answer with no cost. A count does not fail over to another format or another model. Counting through an AWS Bedrock upstream still answers 501not_supported, after which Claude Code counts precisely itself.Reasoning sealed by another account. Reasoning items carry content encrypted or signed for the account that produced them. After a failover from one account to another, the upstream refuses them (Responses’
invalid_encrypted_content, Anthropic’s invalidsignaturein athinkingblock). The request is now sent once more without them, and the refused items are left out up front on the conversation’s later turns to that upstream. - The control-plane protocol version (
-
ThinkWatch Lite v2026.9.24 GitHub ↗
Upgrade notes:
- The bundled core is now 0.55.2, which speaks control-plane protocol 28. A remote server has to run core 0.55.2 too: this version does not connect to 0.54.0, and 2026.9.23 and earlier do not connect to 0.55.x.
- Request history is kept.
- A request that a routing rule sent as another model is now priced by the model it was sent as, so its cost can differ from what an earlier version showed (see Costs).
Amazon Bedrock upstreams: Amazon Bedrock is in the list of services.
- Choose the region and one credential: a Bedrock API key; access keys, with a session token for temporary credentials; or an AWS profile, read from the AWS credential files on the machine the core runs on. Each can be written as
${VARIABLE}. With a remote core, the variables and the profile are read on the server. A profile that signs in or runs a command to get its keys cannot be used, and says so. - Check connection verifies the credential and lists the models the region can route to: foundation models, inference profiles such as
us.anthropic.claude-…andglobal.…, and the account’s own application inference profiles. - Requests in every client format are converted to Bedrock’s Converse API and signed one by one. Streaming, usage, output redaction and tool-call inspection work as with other upstreams, and prompt-cache breakpoints and Claude’s thinking are carried over.
- Bedrock takes its own model IDs. A client that asks for Anthropic’s names, such as
claude-sonnet-4-5, needs a routing rule that changes the model to a Bedrock ID. - For a VPC endpoint or a proxy, enter its address and the region.
- When AWS refuses a credential, its message names the account and the IAM identity; clients get the gateway’s own sentence instead.
- Replaying a request to a Bedrock upstream is refused: a replay sends the recorded request unchanged, and Bedrock takes only the Converse format, which no client sends.
Claude Code and Claude Desktop that used Bedrock directly:
- Claude Code with
CLAUDE_CODE_USE_BEDROCKturned on ignored the gateway address and kept calling Bedrock directly, although it showed as connected. The same held for its other provider switches: Mantle, Google Cloud’s Agent Platform, Microsoft Foundry and Claude Platform on AWS. Connecting Claude Code now turns such a switch off in~/.claude/settings.json, and restoring turns it back on exactly as it was. A switch turned on by an organization’s managed settings cannot be overridden, so connecting is refused with the reason. The configuration check reports a switch that was turned on again later. - The confirmation lists the model variables that name no Bedrock model: those requests reach the gateway under Anthropic’s model names and need a routing rule.
- The confirmation says whether the gateway has an Amazon Bedrock upstream, and New Bedrock upstream… opens the new upstream filled in from the client’s own settings: the region, a custom endpoint, and the credential as a
${VARIABLE}or a profile name. A key kept in plain text in the client’s configuration is not copied; the notes say where it is. Claude Desktop’s Bedrock configuration in use before connecting is recognized the same way. - Credentials in the
envblock ofsettings.json, such as the Bedrock API key and access keys that/setup-bedrockwrites there, are masked in the change shown before connecting. A value written as an empty string is shown as"".
Web search through other formats: Claude Code runs each web search as a request that only an upstream in Anthropic’s format can carry out. Sent to a Bedrock, OpenAI or Gemini upstream, the search tool was dropped and the model’s own answer came back as the search results. Such a request now goes to the next upstream of the route, and otherwise fails with a message naming the tool and the upstream.
Costs:
- A request that a routing rule sent as another model is priced by the model it was sent as. The traffic view still shows the model the client asked for.
- Long-context prices apply from the threshold the price data gives (200K for Claude Sonnet 4 and 4.5, 272K for GPT-5.4 and later), and cache reads and writes count toward it.
- A price the price data does not give, such as a one-hour cache write, is filled in and the cost is marked estimated. A Bedrock model the price data does not list borrows the price of the same model and is marked estimated.
Errors partway through a stream: an upstream that starts answering and then reports an error inside the stream, such as Anthropic’s
overloaded_erroror Responses’response.failed, now leaves the request recorded as failed instead of finished, with the usage reported before the error. -
ThinkWatch Core v0.55.2 GitHub ↗
This release stops a Bedrock API key that is not valid from passing the connection check.
Upgrade notes
- The control-plane protocol version is unchanged (28) and so is the request store’s schema (22). ThinkWatch Lite 2026.9.23 includes 0.54.0 (protocol 27) and does not connect to 0.55.x: a server used with it stays on 0.54.0 until the app is updated to a release that includes 0.55.2.
- No message codes change.
Checking a Bedrock upstream. AWS answers a Bedrock API key that is not valid with
AccessDeniedException(“Authentication failed: …”, “Invalid API Key format: …”), the same exception it uses for a valid credential that may not list models. The check took everyAccessDeniedExceptionon the model listing to mean the credential works, so a mistyped key passed with “The credential works, and it has no permission to list models”, and the background model fetch said the same. Only a refusal whose message says the identity is not authorized, which is how IAM denies an action, now counts as missing list permission; any other one reports the key as rejected. AWS’s message is only inspected and never passed on, since it names the account. -
ThinkWatch Core v0.55.1 GitHub ↗
This release stops a request that requires a server-side tool, such as Claude Code’s web search, from being sent without it to an upstream of another format.
Upgrade notes
- The control-plane protocol version is unchanged (28) and so is the request store’s schema (22). ThinkWatch Lite 2026.9.23 includes 0.54.0 (protocol 27) and does not connect to 0.55.x: a server used with it stays on 0.54.0 until the app is updated to a release that includes 0.55.1.
- New message codes:
gw.convert.tool_unsendableandgw.convert.tools_unsendable.
Requests that require a server-side tool. Claude Code runs each web search as a request that carries only the server-side
web_searchtool and forces the model to use it. Converted for an upstream of another format (AWS Bedrock, OpenAI, Gemini), the server tool was dropped, since only the provider it belongs to runs it: the model answered without searching, and Claude Code passed that answer on as the search results. When the tool a request forces cannot be sent in the upstream’s format, that attempt is no longer made. The next upstream of the route is tried, and if none can take the request the client gets a 400 that names the tool and the upstream. Server-side tools offered alongside other tools, which the model is free to leave unused, are still dropped and listed in the request’s details as before. -
ThinkWatch Core v0.55.0 GitHub ↗
This release adds AWS Bedrock upstreams. Requests to them are converted to Converse and signed per request, and prompt-cache breakpoints and Claude’s thinking now survive that conversion. Each request is priced by the model it was actually sent as, long-context prices follow the thresholds the price data gives, and an error an upstream reports partway through a stream now counts as a failure.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) is now 28. ThinkWatch Lite connects only to a core with the same protocol version, and has to regenerate its protocol types from this release to use it. ThinkWatch Lite 2026.9.23 includes 0.54.0 (protocol 27) and does not connect to 0.55.0: a server used with it stays on 0.54.0 until the app is updated to a release that includes 0.55.0.sudo twcore upgrade --version 0.54.0 --restartswitches a server back to 0.54.0. - The request store’s schema is unchanged (22): the request history is kept.
- Changed in the protocol:
Protocolhasbedrock.ProviderInput.awsandProviderView.aws(AwsKeys): the access keys, or the name of an AWS profile, and the region to sign for.ProviderView.regionandProviderPreview.region: the region of a Bedrock upstream.AttemptView.model: the model an attempt sent when a routing rule rewrote it.
- New in the configuration: the protocol
bedrockandproviders[].aws. The 200K long-context threshold of a price sheet’sinput_above_200kandoutput_above_200know counts cache reads and writes (see Priced by the model that was sent). - New message codes:
config.credential.:bedrock_oauth,key_and_aws,bedrock_no_credential,aws_not_bedrock,aws_empty,aws_profile_and_keys,aws_empty_profile,aws_signed_header,aws_no_region,aws_bad_region,aws_region_mismatchconfig.aws_profile.:no_home,unreadable,not_found,unsupported,no_keysgw.upstream.:aws_token_expired,aws_profile_expired,bedrock_refused,bedrock_refused_unnamed,sign_failed,eventstream_broken,stream_exception,stream_errorgw.probe.aws_token_expired,gw.probe.bedrock_list_denied,gw.count_tokens.bedrock_model,gw.count_tokens.bedrock_upstream,control.replay_bedrock,l3.sign_failed
AWS Bedrock upstreams. An upstream at
https://bedrock-runtime.<region>.amazonaws.comis recognized asbedrock. It authenticates in one of three ways:- A Bedrock API key in
key, sent asAuthorization: Bearer. - AWS access keys in
aws:access_key_id,secret_access_keyand, for temporary credentials,session_token, each of which can be${VAR}. Every request is signed with them (SigV4) once its body is final. aws.profile: the access keys of a profile in the AWS credential files (~/.aws/credentials,~/.aws/config, or the filesAWS_SHARED_CREDENTIALS_FILEandAWS_CONFIG_FILEname) on the machine core runs on. The files are read again when they change, so a tool that refreshes temporary keys there needs no restart. Nothing is run to obtain a credential: a profile that signs in through IAM Identity Center, runs acredential_processor assumes a role is refused, naming the setting.
Requests in every client format are converted to Converse and ConverseStream. The binary eventstream is turned into server-sent events as it arrives, so usage, the first token, output redaction and tool-call inspection work as they do for any other upstream. The
anthropic-betavalues Bedrock accepts for Claude are carried into the request body. The model list comes from the region’s control plane: the foundation models that can be invoked on demand, the inference profiles AWS defines (us.anthropic.claude-…,global.…), and the account’s application inference profiles, listed by the ARN they are invoked by. Checking the connection and the inference speed test go through the same signing. For a VPC endpoint or a proxy, write its address inbase_urland the region inaws.region.- AWS’s text for a refused credential (HTTP 401 or 403) names the account and the IAM identity. The client receives the gateway’s own sentence instead, and AWS’s text is not recorded as the request’s error.
- An expired session token is named as such; for a profile, the message says to refresh the profile.
- An exception AWS sends inside a stream ends the request as a failure.
count_tokensfor a model that only Bedrock upstreams serve is answered with 501not_supported, the answer Claude Code’s guidance for gateways names; Claude Code then counts with a one-token request. Bedrock’s own CountTokens covers only some older Claude models onbedrock-runtime.- A replay to a Bedrock upstream is refused: a replay sends the recorded request as it was, and Bedrock takes only the Converse format, which no client sends.
The code that speaks to Bedrock (signing, the eventstream, the endpoints and the model catalog) is the new layer-one crate
tw-bedrock, which ThinkWatch Enterprise shares.Converse keeps cache breakpoints and thinking. The conversion to Converse used to drop
cache_control, so a client that cached its prompt never hit the cache through Bedrock. Cache breakpoints now becomecachePointblocks (at most four, as Bedrock allows), with the one-hour TTL where the client asked for it, and cache reads and writes, one-hour writes included, are read back. Claude’s thinking and effort are sent inadditionalModelRequestFields, and its reasoning comes back as reasoning content.Priced by the model that was sent. A routing rule can send a request as another model, and the upstream charges for the model it received. Each attempt now records the model it sent when a rule rewrote it (
AttemptView.model), and a request is priced by the model of the attempt that served it. The row still names the model the client asked for. The list of unpriced models names the model a price has to be set for.- A Bedrock model the price data does not list borrows a price, always marked estimated: an inference profile from the model it is named after, and an Anthropic model on Bedrock from Anthropic’s own name for it.
- Long-context tiers are read with the threshold the price data gives (200K for Claude Sonnet 4 and 4.5, 272K for GPT-5.4 and later), including their cache prices. A request’s input counts its cache reads and writes when the tier is chosen. The 272K tier was not recognized before, and a request with a warm cache was priced as a short one.
- A price the data does not give is filled in, and the cost is marked estimated whenever it is used: a one-hour cache write costs twice the input price (it used to fall back to the five-minute price), and the missing cache prices of a long-context tier grow with its input price.
Errors partway through a stream. An upstream can answer 200, stream part of an answer and then report an error in the stream: Anthropic’s
overloaded_error, Responses’response.failed, anerrorin a Chat or Gemini frame. Such a request was recorded as finished; it is now a failure, with the usage the upstream reported before the error.Listed models.
GET /v1/modelsandGET /v1/models/:modelno longer stamp each model with the time of the request.createdandcreated_atare now the Unix epoch, since the gateway does not know when a model was released. - The control-plane protocol version (
-
ThinkWatch Lite v2026.9.23 GitHub ↗
Upgrade notes: the bundled core stays at 0.54.0, so a remote server running 0.54.0 keeps working with this version and nothing on the server has to change. Request history is kept.
Updates keep the window as it was: after an update, the app comes back the way it was before. If it was running in the menu bar only, it stays in the menu bar; if its window was open, the window opens again. Before, the main window opened after every update. This applies to updates the app installs itself, on every platform, and to
brew upgradeon macOS. A Homebrew upgrade now also shows the “Updated to …” notification when Notices is set to System notification.The update from 2026.9.22 to this version is not covered yet: 2026.9.22 does not record the window when it quits, so this one update still opens the window and shows no “Updated to 2026.9.23” notification.
Sidebar: with the sidebar expanded, hovering a page no longer pops up a tooltip that repeats its name. The page’s shortcut (⌘1 to ⌘9, or Ctrl+1 to Ctrl+9 on Windows and Linux) appears at the end of its row while the pointer is over the row or the row has keyboard focus. The collapsed sidebar still shows the name and the shortcut in a tooltip.
-
ThinkWatch Lite v2026.9.22 GitHub ↗
Upgrade notes:
- The bundled core is now 0.54.0, which speaks control-plane protocol 27. A remote server has to run core 0.54.0 too: this version does not connect to an older core, and 2026.9.21 and earlier do not connect to 0.54.0.
- Request history is emptied. The request store changed format, so the requests recorded by an earlier version are removed when the new core first starts. Settings, keys and upstreams are kept.
Time to first token: latency now counts to the first token of the answer instead of to the response headers. The headers of a streamed response usually come back as soon as the upstream receives the request; the first token comes only after the upstream has queued the request and read the prompt. Traffic, the overview and the upstreams page show this time, and the response-header time moves into the request details.
Generation speed: a new figure, in tokens per second, is measured from the first token to the end of the answer.
- Traffic: hovering a request’s latency shows its first token, total time, generation time and speed.
- Request details: the speed is one of the five figures at the top, and the bar is split into the wait for the first token and the generation.
- Overview: a new Generation speed section gives the median speed by model and by upstream.
- Upstreams: the timing column shows the median first token, with the median speed below it.
Some requests have no speed because it cannot be measured honestly:
- responses that are not streamed;
- cancelled and failed requests;
- generations shorter than half a second;
- answers whose model reasoned without showing it, when the upstream does not report how many tokens the reasoning took.
Menu bar:
- The generation speed in the menu was wrong: a single response that was not streamed could push it to tens of thousands of tokens per second. It now uses the same measurement.
- After the computer slept through midnight, Today kept showing the previous day’s tokens and cost until a request came in or the menu was opened. It now catches up as soon as the computer wakes, and when the clock or the time zone is changed. The traffic page, which shows only the time for today’s requests, and the keys page’s usage over the last 24 hours catch up the same way.
Routing: a model name that is not among the suggestions is kept, in a rule’s model condition, in Change model to and in the dry run. Before, closing the suggestions cleared a name the list did not have, such as a relay’s own model name or a model released after the upstream’s list was fetched, and turned a shorter name back into the suggestion picked earlier.
-
ThinkWatch Core v0.54.0 GitHub ↗
This release records when each response’s first token arrives and how fast the response is generated, and corrects the generation rate that
GET /livereports.Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) is now 27. ThinkWatch Lite connects only to a core with the same protocol version, and has to regenerate its protocol types from this release to use it. ThinkWatch Lite 2026.9.21 includes 0.53.0 (protocol 26) and does not connect to 0.54.0: a server used with it stays on 0.53.0 until the app is updated to a release that includes 0.54.0.sudo twcore upgrade --version 0.53.0 --restartswitches a server back to 0.53.0. - The format of the request history changed (request store schema 22, from 21). On its first start, 0.54.0 replaces the request history and the stored request and response bodies of an earlier version with an empty store; the configuration is kept. Switching back to an earlier version empties it again.
- Changed in the protocol: the new event
RequestFirstToken { id, ttft_ms },RequestFinished.tokens_per_sec,HistoryRow.ttft_msandHistoryRow.tokens_per_sec, and the new endpointsGET /token-rateandGET /token-rate/provider, which returnTokenRateView { model, p50, samples }.GET /latencyandGET /latency/providernow report the time to the first token. No message code changed.
First token. The headers of a streamed response usually come back as soon as the upstream receives the request. The upstream then queues the request and reads the prompt before it produces anything, and the time a user waits is the time to the first token, not to the headers. The gateway now reads each successful streamed response in the upstream’s format and reports the moment the first text, reasoning or tool call arrives; opening frames such as
message_startandresponse.createddo not count. A response that is not streamed arrives whole and has no first token.GET /latencyandGET /latency/providernow give percentiles of this time, so responses that are not streamed are left out of them.url-testgroups still pick the upstream with the fastest response headers.Generation speed. Each finished streamed request has a generation speed: the output produced after the first token, divided by the time from the first token to the end.
- When the model reasoned before the first token without streaming its reasoning, those reasoning tokens were generated outside that time. They are subtracted when the upstream reports how many there were, as OpenAI and Gemini do. When it does not, as with Anthropic, the request has no speed rather than one several times too high.
- Reasoning that is streamed as it happens is counted.
- Responses that are not streamed, cancelled and failed requests, and generations shorter than half a second have no speed.
GET /token-rateandGET /token-rate/providergive the median speed by model and by upstream, with the number of samples.The live rate.
GET /liveused to divide each request’s output by the time after its response headers. A response that is not streamed gets its headers only when generation is done, so that time was a few milliseconds, and one such request could push the rate to tens of thousands of tokens per second. The rate now combines the speeds of the requests that have one, weighted by their generation time. - The control-plane protocol version (
-
ThinkWatch Lite v2026.9.21 GitHub ↗
Upgrade notes: the bundled core is now 0.53.0, which speaks control-plane protocol 26. A remote server has to run core 0.53.0 too: this version does not connect to an older core, and 2026.9.20 and earlier do not connect to 0.53.0. Request history is kept. Upstreams now see ThinkWatch’s User-Agent instead of the client’s, so an upstream that admits only certain clients, such as Kimi For Coding, Bailian Coding Plan or a relay restricted to official clients, needs the new Forward client identity switch in its connection settings.
What upstreams receive:
- Each upstream receives the request and only the headers its API needs. Before, almost every header a client sent went along: Claude Code’s User-Agent,
x-app, SDK details and session ID, Gemini CLI’s installation ID, and a browser client’s cookies and origin. Upstreams now see ThinkWatch’s User-Agent and the credential and headers configured for the upstream. From the client’s request they get only the headers of the upstream’s own protocol:anthropic-*for Anthropic,Idempotency-KeyandX-Client-Request-Idfor OpenAI, and none for Gemini. - Identity fields that clients fill in on their own are removed from the request body, such as Claude Code’s
metadata.user_id, which holds a device ID, an account ID and a session ID. - Forward client identity, a new switch in the connection section of the upstream dialog, sends that upstream the client’s own User-Agent and identity, unchanged. It is off by default, including for upstreams created by the Z.ai sign-in, and ChatGPT accounts do not offer it.
ChatGPT accounts:
- Newer models that the account can use, such as GPT-6-Luna, appear in the upstream’s model list and can be requested. Before, the gateway answered “No upstream serves model gpt-6-luna” even though the account had the model.
- Requests from the Codex in the ChatGPT app no longer pass that app’s attestation and installation ID on to OpenAI. On one account, OpenAI revoked the sign-in within seconds of the first such request.
- Request bodies are compressed with zstd, as Codex does, so long conversations upload faster.
- Each upstream receives the request and only the headers its API needs. Before, almost every header a client sent went along: Claude Code’s User-Agent,
-
ThinkWatch Core v0.53.0 GitHub ↗
This release changes what ThinkWatch sends to upstreams. Each upstream now receives the request and only the headers it needs; the client’s own identity stays with ThinkWatch unless an upstream asks for it. It also fixes two problems with ChatGPT account upstreams.
Upgrade notes
- The control-plane protocol version is now 26 (it was 25). The request store’s schema is unchanged (21). ThinkWatch Lite 2026.9.20 includes 0.52.1 (protocol 25) and does not connect to 0.53.0, so a server used with it stays on 0.52.x until the app is updated to a release that includes 0.53.0.
- Upstreams now see ThinkWatch’s
User-Agentinstead of the client’s. An upstream that admits only certain clients, such as Kimi For Coding, Bailian Coding Plan or a relay restricted to official clients, needs the new provider fieldforward_client_identity: true. - New message code:
config.credential.chatgpt_client_identity.
Each upstream gets only what it needs. Client request headers used to reach the upstream almost unchanged. Only credentials, a few transport headers and, when a request was translated, the client format’s own protocol headers were removed. Claude Code’s
User-Agent,x-app,x-stainless-*and session ID, Gemini CLI’s installation ID and a browser client’sCookieandOriginall went through. Outbound headers now come from three sources only:- Set by ThinkWatch: its own
User-Agent, andcontent-typewhen the body was translated. - The upstream’s configuration: the credential and the headers written there.
OpenAI-OrganizationandOpenAI-Projectbelong here, because they belong to the upstream’s key. - The client’s request, only for the headers the upstream’s protocol uses:
- Anthropic:
anthropic-*, exceptanthropic-dangerous-direct-browser-access. - OpenAI:
Idempotency-KeyandX-Client-Request-Id. - Gemini: none.
- Every protocol:
content-typeandacceptwhen the body passes through unchanged.
- Anthropic:
An Anthropic request without
anthropic-versionnow gets the default one. When the body passes through unchanged, identity fields that clients fill in themselves are removed from it: Claude Code’smetadata.user_id, and the Codex installation ID and turn metadata inclient_metadata.forward_client_identity. With this field on, an upstream also receives the client’s ownUser-Agent,x-app,originator,version,session_idandx-codex-*headers, and the body’s identity fields are kept. These are the client’s own values; nothing is made up. The field is off by default. A ChatGPT account upstream refuses it: that upstream always names ThinkWatch as the sender.ChatGPT account upstreams.
- New models were missing from the list. The Codex backend leaves a model out of the list when its minimum client version is above the
client_versionthat the caller reports. ThinkWatch reported0.0.0, so a model such as gpt-6-luna, which needs Codex 0.155.0, was not listed, and requests for it were refused with “No upstream serves model …” even though the account can use it. ThinkWatch now reports99999.0.0, so the list shows every model the account can use. Requests still name ThinkWatch as their origin. - The ChatGPT app’s identity is no longer forwarded. The Codex inside the ChatGPT desktop app adds an attestation generated by the app (
x-oai-attestation), its installation ID and turn metadata. ThinkWatch forwarded them together with the token it had obtained itself, and on one account OpenAI revoked that token within seconds of the first such request.- From the client, the Codex backend now receives only
x-codex-turn-state,x-openai-internal-codex-responses-liteandx-codex-beta-features. - The attestation, installation ID and turn metadata cannot be set in an upstream’s configured headers.
- From the client, the Codex backend now receives only
- Request bodies are compressed. Requests to the Codex backend are compressed with zstd, which is what Codex does when it talks to that backend itself.
-
ThinkWatch Lite v2026.9.20 GitHub ↗
Upgrade notes: the bundled core stays at 0.52.1, so a remote server running 0.52.1 keeps working with this version and nothing on the server has to change.
Connecting and restoring clients:
- A change is written only as it was shown. If a client rewrites its configuration while the confirmation dialog is open, nothing is written and the dialog shows the change worked out from the file as it is now, to confirm again. This applies to connecting and restoring a client, copying or removing an MCP server, and switching WSL to mirrored networking.
- Restoring no longer deletes sections that connecting had created once something else was added to them: Claude Code’s
envinsettings.json, theprovidersection of a new opencode configuration, and DeepSeek Harness’srefs. Restoring DeepSeek Harness no longer fails with “refs is not a scalar”, and its credentials file keeps itsversionwhen DeepSeek Harness has written its own entries into it. - A configuration file that contains an empty object written as
{ }no longer gets a stray comma when a setting is added to it. Claude Code and Claude Desktop rejected such a file. - In TOML files, a comment at the end of a changed line is kept, and an MCP server copied from a JSON client no longer gets empty strings where it had
null. - When the record of an earlier connection next to a client’s configuration is damaged or belongs to another client, connecting is refused, as restoring already was. Before, it recorded the gateway’s address and key as the original values, and a later restore put them back.
- Keys no longer appear in the change shown before writing: restoring showed the user’s own key, and copying an MCP server showed the environment variables and headers of every server in the file. Messages about a configuration file that cannot be parsed no longer quote the line that failed, and a failed write no longer leaves a temporary copy with the gateway key next to the configuration.
- Backups are readable by their owner only, and a configuration folder that is a symbolic link (for example into a dotfiles repository) is pointed out before writing.
- Pointing Claude Desktop at a new address or key, after a key change or when switching to a remote core, updates its model list too.
- On Windows, a running client is recognized by its program name, so a program such as TombRaider.exe is no longer taken for Aider.
- Uninstalling keeps the data folder when a client could not be restored, since it holds that client’s only full backup. It stops the core before deleting the data, and its confirmation also lists the clients inside WSL that will be restored.
- The “Change path…” dialog no longer freezes the window while a network path that is offline takes its time to answer.
- On a new installation, the data folder is created readable by its owner only, as intended. It holds
config.yamlwith the upstream keys, and on Windows its access rules are their only protection.
Figures:
- When a statistic cannot be read, the Overview says it is unavailable and the Upstreams page shows ”—” with a Retry button, instead of zeros or an empty chart.
- Today’s cost in the menu bar, and the costs on the Keys and Upstreams pages, keep measured, estimated and unpriced apart, as the Overview does. A cost is no longer shown as $0 when the requests could not be priced.
- Very small amounts are shown as ”<$0.0001” instead of “$0.0000”. Numbers change unit after rounding, so they read “10k” and “1.0M” rather than “10.0k” and “1000k”.
- In live mode, requests whose price has not arrived yet make the total a lower bound, marked ”≥”, instead of counting as $0.
- In a session, a turn is marked Unpriced only when a price would fix it. “Show unpriced only” on the Traffic page no longer lists failed requests and requests without usage.
- Requests in progress when a remote connection dropped are corrected from the server’s history instead of staying marked as failed.
Time ranges: “Today” in the menu bar starts at midnight on this computer, also when the core runs on a server in another time zone and on the days clocks change. A custom range such as “since 9/8” keeps its start day while the page stays open. Long custom ranges are drawn with daily or weekly points and always reach the present; ranges over about four months used to stop short of it.
Core and remote connections:
- Switching to a remote core also stops a local core that was still starting or restarting. Before, it kept being started and held the gateway port.
- A core in safe mode is shown as being in safe mode, not as running, and Restart works from there.
- A remote connection that died without a sign, for example after the computer slept or the network changed, is noticed within 45 seconds, and a server that accepts the connection but never answers makes the connection test time out.
- Quitting the app stops the local core instead of leaving it to notice a few seconds later.
Notifications:
- A tool call blocked by the security rules, and something suspicious appearing in a client’s configuration, are reported again each time they happen. Before, only the first one was ever reported, even after a restart. To keep a burst from flooding the screen, each shows at most once every five minutes (per upstream for blocked calls), and one notification then sums up the rest.
- A notification that waits a minute before showing is dropped if the gateway went down meanwhile. The “N more” count no longer counts the same notice twice. The title of an upstream that keeps dropping out no longer grows longer with each request.
- “Updated to …” follows the notification setting.
- The notice list and the settings are saved in a way that survives the app being killed mid-save, and a setting this version does not recognize, such as one written by a newer version, resets only that setting.
Menu bar and tray: the color reflects the most severe quota window, not only the fullest one. On Windows and Linux, menu actions such as Check for Updates report their result in a notification, and messages from the core appear in the interface language. A click on a menu that has just been replaced no longer runs a different item. The menu bar no longer stops updating when the core stops answering.
MCP and the security scan:
- The scan also covers MCP servers configured per project in
~/.claude.json, Claude Code settings that run commands without the model (statusLine,apiKeyHelperand similar), and Codex’snotify. Helpers that print credentials are scanned but not listed, so their commands do not appear on screen. - Skills and folders created after the app started are watched as well, and so is
opencode.jsonc. - A remote MCP server is no longer taken for a local one because its address contains “localhost”.
SKILL.mdfiles with Windows line endings or a byte order mark are read correctly. - A file full of hidden characters no longer stalls the scan; findings are capped per file, with a note saying where the scan stopped. A special file, such as a link to
/dev/zero, no longer blocks it. - Findings in a hook’s command, including hidden characters, are shown on that hook’s row.
Editing:
- A
config.yamlwith Windows line endings no longer shows unsaved changes on opening, and is saved with its line endings as they were. “Show in config file” selects the right entry when a key and a route share a name. - Edit dialogs save against the configuration they were opened with. A change made elsewhere meanwhile is reported as a conflict instead of being overwritten. Switching several items on or off in quick succession no longer fails with “version mismatch”.
- Price fields and the dry run accept a decimal comma.
- In the upstream dialog, the connection check result is cleared when the connection settings change.
- In a key’s model scope, model IDs typed by hand are shown and can be removed even when the model list is empty, and they are compared without regard to case, as the gateway does.
- Saving a route applies its auxiliary request categories as they are after the edit: unticked or deleted conditions no longer keep them assigned to routing.
max_tokensof 0 is refused instead of being dropped on save, and a copy of the fallback rule can be moved ahead of it. - A replayed request goes to the upstream it was priced for.
- Several dialogs no longer act on stale state: the speed test waits for the new quote before it can start, deleting a price sheet from the upstream dialog also clears it from the form, checking for updates from About no longer overwrites settings changed meanwhile, dismissing one key-rotation banner no longer dismisses the others, and Z.ai sign-in accepts an existing upstream whose address ends in a slash.
- A small payload cap in Retention no longer blocks saving that section.
- Ctrl/⌘+B no longer toggles the sidebar while typing in a text field.
- On Windows, choosing a connection at startup and opening the update window no longer risk freezing the app.
Speed: the update and connection windows load a fraction of the code they used to, and the configuration editor loads when it is opened. The change shown before writing a large
~/.claude.jsonappears at once. The Traffic page does less work per event, and the macOS menu bar updates its menu in place while it is open. -
ThinkWatch Lite v2026.9.19 GitHub ↗
Upgrade notes: the bundled core is upgraded to 0.52.1. The request store keeps its format, so the request history is kept. A remote server has to run the same core version, 0.52.1 (ThinkWatch Lite 2026.9.18 cannot connect to a server running 0.52.x), so the server and the app are updated together. When the versions differ, the app gives the command to run on the server,
sudo twcore upgrade --version 0.52.1 --restart. A server upgraded from 0.51.0 keeps its request history as well.Upstreams and proxies: the edit dialogs show the settings as they are written in the configuration.
- The base URL keeps its path. It used to be cut down to its origin, under a note that the credentials in the URL are hidden.
- The API key is filled in and hidden, with an eye button to show it, and the authorization header under Headers follows the same button. A
${NAME}reference stays visible. The other header values are filled in as written. - OAuth upstreams have their token endpoint, refresh token, client ID and client secret filled in, the secrets hidden. The access token field appears only once the credential is new or changed.
- A proxy’s username and password are filled in, the password hidden.
- A save sends the whole definition, so the “leave empty to keep”, “Remove” and “Replace” states are gone.
- A base URL that ends with its version segment, as provider docs give it (
https://api.openai.com/v1), no longer has every request and connection check sent to…/v1/v1/…, and the hint under the base URL now only asks to leave out an endpoint path such as/chat/completions. - A proxy password can be a
${NAME}reference. A password containing${used to be refused. - Z.ai sign-in: entering the name of the existing Z.ai upstream was refused as a name already in use. The dialog now says that the sign-in replaces that upstream’s key, and the sign-in can go ahead.
Routing:
- A rule that rewrites the model was refused with “No upstream serves model …” whenever the target upstream lists its models. That is the rule the app suggests for Claude Desktop with a non-Claude upstream, and for Antigravity CLI and DeepSeek Harness with another provider’s models. Admission, skipping the candidates that cannot serve a request, the cheapest group’s price comparison and the routing dry run now look at the model each upstream is actually asked for. When a rewritten model is not served, the error names both models.
- Requests from Codex and other clients that use the OpenAI Responses or Gemini format are recognized as conversations. A load-balance group with sticky sessions, the default, sent all of them to its first upstream. Each conversation now stays on one upstream while the conversations are spread across the members, and the Traffic page groups them into sessions.
Clients: a client’s row menu on the Clients page (right-click or the … button) has “Change path…”, for a client whose files are somewhere else, such as Claude Code moved with
CLAUDE_CONFIG_DIRor Codex moved withCODEX_HOME. It covers the clients under “Set up by hand” too, such as Cursor and Antigravity CLI. The dialog shows where connecting, MCP management and the security scan read and write. They move together: before anything changes, the dialog lists every location that moves, old and new. “Restore defaults” puts them back. The same dialog opens from the MCP page, by right-clicking a column header or a row under “Skills and hooks” or “Findings”. A connected client has to be restored first. The locations apply to this computer only, and clients inside WSL keep their default locations. Claude Desktop and DeepSeek Harness cannot be moved.Remote connections: editing a remote connection fills in the saved key, hidden behind an eye button, so it can be checked or copied.
Menu bar and tray: the menu now reads, from the top: the gateway’s state, Notices, Today, Quota and In Progress, then Open ThinkWatch Lite, the groups whose upstream can be chosen, Copy Gateway Address and Copy Default Key, then Settings…, Connection, Check for Updates… and Quit ThinkWatch Lite…. On Linux, Open ThinkWatch Lite stays at the very top of the tray menu. “Undo Last Configuration Change” is gone; a configuration change can still be restored from the version history in the app. A quota window named by its length, such as the 30-day window a ChatGPT account can report (shown as
30d), is spelled out in the menu, on the Upstreams page and in notifications: “30 days”, or “30-day window” in a sentence. -
ThinkWatch Core v0.52.1 GitHub ↗
This release fixes two routing problems: a routing rule that rewrites the model was judged by the model the client asked for, and requests in the OpenAI Responses and Gemini formats were never recognized as part of a conversation.
Upgrade notes
- The control-plane protocol version is unchanged (25) and so is the request store’s schema (21). ThinkWatch Lite 2026.9.18 includes 0.51.0 (protocol 24) and does not connect to 0.52.x: a server used with it stays on 0.51.0 until the app is updated to a release that includes 0.52.1.
- New message codes:
gw.model.no_upstream_rewrittenandgw.model.not_allowed_rewritten.
Rewriting the model. A rule that sets the model, for example to send the Claude-style names that Claude Desktop requires to a GLM or DeepSeek upstream, was refused with “No upstream serves model …” whenever the target upstream lists its models. Admission, skipping the candidates that cannot serve a request, and the cheapest group’s price comparison all looked at the model the client wrote, before any rule rewrote it. They now look at the model each upstream will actually be asked for, including a rewrite that applies to one upstream only (
provider_would_be), so admission runs after the rules have decided. When a rewritten model is not served, the error names both models. The routing dry run judges the candidates the same way.Conversations in the Responses and Gemini formats. Requests from Codex and other clients that use the OpenAI Responses or Gemini format were never assigned to a session, because the fingerprint read only the Anthropic and Chat Completions fields. A load-balancing group with session affinity, which is the default, sent all of them to its first upstream, and the traffic view did not group them into sessions. The fingerprint now also reads
instructionsandinput, andsystemInstructionandcontents, and uses the request’sprompt_cache_keywhen it has one. Codex sends one per conversation, and Codex conversations started in the same repository open with identical text. -
ThinkWatch Core v0.52.0 GitHub ↗
This release hands ThinkWatch Lite the upstream and proxy settings as they are written in the configuration, so its edit dialogs can show them, and stops repeating the version segment when an upstream’s base URL ends with it.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) is now 25. ThinkWatch Lite connects only to a core with the same protocol version, and has to regenerate its protocol types from this release to use it. ThinkWatch Lite 2026.9.18 includes 0.51.0 (protocol 24) and does not connect to 0.52.0: a server used with it stays on 0.51.0 until the app is updated to a release that includes 0.52.0.sudo twcore upgrade --version 0.51.0 --restartswitches a server back to 0.51.0. - The request store’s schema is unchanged (21). Upgrading from 0.50.x or 0.51.0 keeps the request history.
- Changed in the protocol:
ProviderView.base_url,ProviderView.key(now a string),HeaderView.value, the newOAuthView.refreshandOAuthView.client_secret, andProxyView.auth(which replaceshas_auth) carry the settings as written;base_url_masked,SecretViewandHeaderView.maskedare gone.ProviderInput.base_urlis required,ProviderInput.keyis an optional string and everyHeaderInputhas avalue;ProxyInput.authis optional.SecretChangeandProxyAuthInputare gone, and so is the message codecontrol.pass_has_expansion.
Settings as written. The upstream and proxy views used to mask what they showed. The base URL was cut down to its origin, so
https://bedrock-mantle.us-east-1.api.aws/v1was shown without/v1, with a note that credentials had been hidden when there were none. API keys and header values were masked, and OAuth refresh tokens, client secrets and proxy credentials were left out. The app could not fill its edit dialogs in, and every save carried “keep what is saved” states instead of values. The views now carry the settings as they are written inconfig.yaml, and a save carries the whole definition. The access token of an OAuth credential stays out of the view, and an OAuth credential can still be kept as it is on save: while a dialog is open the gateway may renew the token and write back a new refresh token.GET /configalready returned the file unmasked. Diagnostics bundles and logs still mask.Proxy passwords from the environment. A proxy password may be a
${NAME}reference, as the configuration reference shows (pass: ${PROXY_PASSWORD}). A password containing${used to be refused.Base URLs that end with a version segment. A base URL written up to its version segment, as provider docs give it (
https://api.openai.com/v1) and as the configuration reference says, got every request and every connection check sent to.../v1/v1/...: the client’s path (/v1/chat/completions) was appended as it was. When the base URL’s path ends with the version segment the request path starts with (v1,v2,v1beta, …), that segment is now used once. Other segments are joined as before. - The control-plane protocol version (
-
ThinkWatch Lite v2026.9.18 GitHub ↗
Upgrade notes: the bundled core is upgraded to 0.51.0. The request store has a new format: on its first start, the new core empties the request history, the stored request and response bodies and the security log, and statistics such as usage and cost start again from zero. The configuration is kept. A remote server has to run the same core version, 0.51.0 (ThinkWatch Lite 2026.9.17 cannot connect to a server running 0.51.0), so the server and the app are updated together. When the versions differ, the app gives the command to run on the server,
sudo twcore upgrade --version 0.51.0 --restart. When the core on a server is upgraded from 0.49.x, its first start empties the server’s request history as well.Clients:
- Claude Desktop can be pointed at the gateway in one step, through its third-party inference mode. Afterwards Claude Desktop has to be quit completely and reopened; if the sign-in page appears, choose to continue with the gateway. Conversations in this mode are kept apart from the others. A Claude Desktop managed by an organization cannot be pointed at the gateway.
- DeepSeek Harness can be pointed at the gateway in one step, and its web and desktop apps then send their requests through the gateway. Its web search still goes to DeepSeek directly. The MCP page lists its MCP servers, and the scan covers its patches and skills.
- Antigravity CLI takes the place of Gemini CLI on the Clients page, with steps to set it up by hand and a key of its own. Gemini CLI is no longer listed; a Gemini CLI that is already set up keeps working. The MCP page lists Antigravity CLI’s MCP servers, and the scan covers its hooks, MCP configuration, skills and subagents.
- opencode: once opencode was pointed at the gateway, no ThinkWatch model could be selected. The provider it gets now carries the SDK package and the models the client’s key can use, for opencode 1.x and 2.x. When upstreams or routes change, the Clients page says that opencode’s model list needs updating. The MCP page reads the MCP servers of opencode 2.x (
mcp.servers), and the scan coversopencode.jsonc. - Codex: after Codex is restored, the sessions started while it pointed at the gateway can still be opened, and go to OpenAI directly. The notes shown before pointing Codex at the gateway say that sessions started before and after are listed separately.
- WSL on Windows: Claude Code and Codex inside WSL can be pointed at the gateway, under WSL 1 or under WSL 2 with mirrored networking. Both reach the gateway at 127.0.0.1, and the gateway keeps listening on this computer only. When WSL 2 uses NAT networking, the Clients page can switch it to mirrored networking and restart WSL; mirrored networking needs Windows 11 22H2 or later. When the app switches to a remote core, the clients inside WSL can be pointed at the server as well.
Upstreams: GLM Coding Plan upstreams (Z.ai and BigModel) show their 5-hour and weekly quota and the reset times on the Upstreams page, in the menu bar and in the tray, and credit-based plans show the credits left. A used-up quota sends a notification. The monthly count of calls to Z.ai’s own MCP tools in older plans is not shown.
Traffic: the request details show when a request from DeepSeek Harness carried a session log, and its size. Before such a request reaches an upstream other than DeepSeek, the gateway removes the session log and the other DeepSeek Harness extensions.
Gateway: it answers the startup check of Claude Desktop’s third-party mode and keeps a stream that stays silent for a long time alive.
/v1/filesis no longer forwarded to an upstream. A gateway set to listen on a network interface that is not there yet starts on this computer only and adds the interface once it appears. -
ThinkWatch Core v0.51.0 GitHub ↗
This release stops counting the MCP tool calls of the older GLM Coding Plans as a quota, marks only the weekly window when a model request gets business code 1310, and tells the app at once when a GLM key turns out to have no plan.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) is now 24. ThinkWatch Lite connects only to a core with the same protocol version, and has to regenerate its protocol types from this release to use it. ThinkWatch Lite 2026.9.17 includes 0.49.0 (protocol 22) and no released version of the app includes 0.50.0, so a server used with the app stays on 0.49.x until the app is updated to a release that includes 0.51.0.sudo twcore upgrade --version 0.49.1 --restartswitches a server back to 0.49.1. - The request store’s schema is unchanged (21). Upgrading from 0.50.0 keeps the request history. Upgrading from 0.49.x or earlier replaces the request history and the stored bodies with an empty store on the first start, as 0.50.0 does; the configuration is kept. Switching back to 0.49.x empties it again.
- Changed in the protocol: the quota window
monthlyis gone, and aQuotaSeenwith emptywindowsmeans the upstream’s quota was taken down. No endpoint, type or message code changed.
MCP calls are not a quota. The quota endpoint of the older GLM Coding Plans also reports a count (
TIME_LIMIT) of calls to Z.ai’s own MCP tools: search-prime, web-reader and zread. Those calls do not go through the gateway, so the count says nothing about the requests it forwards. Core no longer reads it: there is nomonthlywindow, and a used-up count no longer marks the upstream used up or emitsQuotaExhausted.creditsare reported only for the windows of credit-based plans (CREDIT_LIMIT).1310 is the weekly quota. A 429 with business code 1310 on a model request marks the
weeklywindow used up, whatever its message says. 1308 still marks the 5-hour window. For 1316 to 1321 the window still comes from the message when it names the 5-hour or weekly window; otherwise it is the fuller of the two known windows, or the weekly one when neither is known.A key without a plan. When a GLM key is found to have no plan, core clears the quota it had stored for that upstream and emits a
QuotaSeenwith emptywindows, so the app removes the quota at once instead of on its next read of/quota. - The control-plane protocol version (
-
ThinkWatch Core v0.50.0 GitHub ↗
This release lets Claude Desktop’s third-party mode use the gateway, cleans the extensions DeepSeek Harness adds to its requests before they reach an upstream other than DeepSeek, reads the GLM Coding Plan quota of Z.ai and BigModel upstreams, and recognises requests from Antigravity CLI. A gateway bound to a network interface now starts even when that interface is not there yet.
Upgrade notes
- The format of the request history changed (request store schema 21, from 20). On its first start, 0.50.0 replaces the request history and the stored request and response bodies of an earlier version with an empty store; the configuration is kept. Switching back to an earlier version empties it again.
- The control-plane protocol version (
CONTROL_API_VERSION) is now 23. ThinkWatch Lite connects only to a core with the same protocol version, and has to regenerate its protocol types from this release to use it. ThinkWatch Lite 2026.9.17 includes 0.49.0 (protocol 22) and does not connect to 0.50.0: a server used with it stays on 0.49.x until the app is updated to a release that includes 0.50.0.sudo twcore upgrade --version 0.49.1 --restartswitches a server back to 0.49.1. - New in the protocol:
QuotaWindow.credits(a new type,QuotaCredits { total, used, remaining }), the quota windowmonthly, andsession_log_bytesonRequestStartedandHistoryRow. New message code:gw.files.unsupported. /v1/filesand/filesnow answer 404 on the gateway instead of being forwarded to an upstream.
Claude Desktop. Claude Desktop in third-party inference mode, and the Claude Code engine it embeds, expect a few things of a gateway:
HEAD /api/hello, the probe Desktop sends at startup, is answered with 200 by the gateway itself, without a key and without reaching an upstream. It used to be refused with 401.- The client gives up on a stream that is silent for about five minutes. When a client speaking Anthropic Messages has received nothing for 15 seconds, the gateway writes
event: ping, so a converted upstream that is reasoning or queued at length no longer looks like a dead connection. The timer runs from the last bytes the client received, not from the upstream’s, because conversion drops the upstream’s own keep-alive comments. Pings go only between frames, are not recorded in the stored body or counted as output, and are never sent to clients of other formats. After ten minutes without a byte from the upstream the gateway stops pinging, so a half-open upstream connection ends on the client’s timer instead of lasting forever. /v1/modelsanswers Anthropic clients in the Anthropic shape:type,display_name,created_at,has_more,first_idandlast_id, and, for models whose id reads as Claude,anthropic_family_tier, which Desktop uses to resolve aliases such assonnet. The OpenAI fields stay on the objects, because clients that send onlyx-api-keyare also read as Anthropic.
DeepSeek Harness. DeepSeek Harness sends DeepSeek’s extensions to whichever base URL it is given: top-level
dsh_*fields, among them a session log of the whole conversation of up to 8 MiB per request,role: systementries inmessages,tool_additionandtool_removalblocks withdefer_loadingtools,thinkingenabled without a budget, andx-deepseek-harness-*headers. A request is recognised by its User-Agent or adsh_*field. To DeepSeek’s official endpoint it goes through unchanged. To any other upstream, every request from it, includingcount_tokens, has thedsh_*fields removed, its system entries appended tosystemin order, the tool change blocks anddefer_loadingremoved and listed on the hop as a conversion lists what it drops, and athinkingwithout a budget rewritten to what the model accepts. Thex-deepseek-harness-*headers are not forwarded, and the tool changes beta is taken out ofanthropic-beta, with every line of a header sent on several lines kept.session_log_bytesgives the size of the session log a request carried, so the app can show that it carried one./v1/filesand/filesanswer 404: a file uploaded to the upstream picked at that moment cannot be used by a later request routed to another, and the 404 makes DeepSeek Harness send images inline.GLM Coding Plan quota. An upstream whose
base_urlhost isapi.z.aioropen.bigmodel.cnis read as a GLM Coding Plan upstream. Its responses carry no quota headers, soGET /quotaasks the account’s quota endpoint for each enabled one that is due (at most once a minute, waiting up to 5 seconds), and traffic to one asks at most every 5 minutes; failures back off from 30 seconds to 5 minutes. Windows are5h,weeklyandmonthly(the monthly MCP calls of the older plans), told apart by their length, and a reset time beyond a window’s length is dropped. Credit-based plans reportcreditswith the three numbers the endpoint gives. A 429 carrying a used-up quota code marks the window used up and emitsQuotaExhausted. A key without a plan is left alone for an hour, and a key that had a quota needs two no-plan answers in a row before it is believed, because a transient server error returns the same code.Antigravity CLI. Antigravity CLI sends the Go genai SDK’s default user agent. A user agent that starts with
google-genai-sdkand carries Google’s internal Go toolchain (gl-go/followed bycl/) is now reported as the clientantigravity-cli. Programs built on the SDK with a public Go release are not.Listening on an interface that is not there yet. When
listen.gateway.bindnames a network interface that does not exist or is offline, or an address that is not on this machine yet, twcore used to exit at startup. The gateway now listens on 127.0.0.1 only and says why in the listen status (gw.listen.no_such_nic,gw.listen.nic_offline), asks the system again every three seconds, adds the interface once it appears, moves to its new address when it changes, and drops back to loopback when it goes away. This lets the desktop app on Windows bind the gateway to the WSL adapter, which appears only once WSL has started and changes address on every restart. A loopback that cannot be bound is still fatal at startup. -
ThinkWatch Core v0.49.1 GitHub ↗
This release changes only the server guide, docs/server.md and its Chinese version, so that it describes the desktop app’s side of a remote connection as the app behaves. The binaries behave as in 0.49.0.
Upgrade notes
- The control-plane protocol version is unchanged (22) and so is the request store’s schema (20). A server on 0.49.0 does not need to upgrade, and ThinkWatch Lite 2026.9.17, which includes 0.49.0, connects to 0.49.0 and 0.49.1 alike.
Connecting from the desktop app. The guide names the settings section Connection and the Add remote connection dialog, which also asks for a name. Test connection tests without saving, Save and switch tests before it saves and asks before it switches, and Save stores the connection untested; switching to a remote connection always tests it first and stays on the current connection if the test fails.
Which version a server runs. The app connects only to a core that speaks its control-plane protocol version, which in practice means the core version the app includes, and the latest release can be newer than that. The guide therefore installs and switches with
--version <version>, notes thattwcore upgrade --versionalso installs an older release, and says that recent versions of the app (2026.9.17 and later) showsudo twcore upgrade --version <version> --restartwith the version they need when the versions differ. It also says thattwcore upgradeleaves the data alone, while a version whose request store has another schema starts with an empty request history.While connected to a remote core. The guide adds that the app signs ChatGPT accounts in with a device code only, next to the three things a remote connection cannot do.
-
ThinkWatch Lite v2026.9.17 GitHub ↗
Upgrade notes: the bundled core is upgraded to 0.49.0. The request store has a new format: on its first start, the new core empties the request history, the stored request and response bodies and the security log, and statistics such as usage and cost start again from zero. The configuration is kept. A remote server has to run the same core version, 0.49.0 (ThinkWatch Lite 2026.9.16 cannot connect to a server running 0.49.0), so the server and the app are updated together. When the versions differ, the app gives the command to run on the server,
sudo twcore upgrade --version 0.49.0 --restart, which applies whether the installed version is newer or older. When the core on a server is upgraded from 0.47.0, its first start empties the server’s request history as well.Redesigned interface: every page is rebuilt on a common design, with a summary in the page header, skeletons while data loads and a Retry button when a read fails. Turning keys, upstreams, security rules and similar items on or off can be undone from the notification that follows. On macOS, the sidebar of the main window is translucent.
Overview: the model, cache, latency and upstream rows and the failure marks can be clicked, and open Traffic filtered to what was clicked; a model is matched by its exact name. The model ranking distinguishes Unpriced, No usage and a cost that is actually zero, and when a model has unpriced requests, clicking its cost shows those requests. The latency section now covers the selected time range; it used to cover the current day only.
Traffic: the page header shows the requests in progress, the failed requests and the requests per minute over the last 30 minutes; clicking the failed count shows failed requests only. A request in progress shows how long it has been running, updated every second, and belongs to its session from the moment it starts. Requests that a rule denies before an upstream is chosen, and requests that no upstream could serve, are now recorded, marked Denied or No upstream, and counted as failures. The Routing tab of the request details lists the route, the matched rule, the group, the rules that rewrote the request, the rule that denied it and the reason. The token column shows the total input, including cache reads and cache writes, followed by the output.
Clients and Keys: the Clients page counts clients by state in its header, and its Set up by hand and Not detected sections can be collapsed. On the Keys page, each key shows its requests per hour over the last 24 hours and is marked In progress while one of its requests is running.
Upstreams: each upstream shows its vendor’s logo and its requests per hour over the last 24 hours, with the hours that had failures marked in red; the row menu has a new View traffic item. The email and plan of a ChatGPT account are now read from the saved credential, without a network request, and are the same everywhere; once the sign-in has expired, the account is still named and its quota is no longer queried. Plans carry the names that Codex gives them. A completed ChatGPT sign-in names the account that was signed in, and a completed Z.ai sign-in no longer occasionally leaves out the account name.
Routing: a map at the top of the page shows where requests go, from keys through routes and groups to upstreams. Hovering a table row or a node highlights the paths through it, and a request in progress lights its path according to core’s routing decision. Each rule shows its hits over the last 7 days, a rule without hits in that time is marked No hits, and on the map the lines leaving a route grow thicker with the number of requests. When fewer than 7 days of requests are recorded, the counts are labelled with the span the records cover, such as 5-hour hits.
Security and MCP: the Security page header shows how many protections are enforcing, observing or off, and the exact number of matches in the selected period, broken down by outcome. The log is grouped by day. A single click opens a rule, where a double click was needed before. The MCP page header summarizes the findings by severity and the time of the scan; clicking a server row shows its configuration in each client, with the fields that differ marked.
Settings: an index of the sections on the left follows the scrolling and marks a section with unsaved changes; unsaved changes remain after switching pages. The page header shows the current connection, the gateway address and the version. Uninstall is a confirmation dialog that lists what will be done and reports the result of each step, and its title says when a step failed.
Command palette: ⌘K opens the command palette (on Windows and Linux, Ctrl takes the place of ⌘ here and below). It opens pages; runs actions such as creating items, the inference and connection tests, the routing dry run and switching connections; and searches upstreams, keys, routes, clients, Settings sections, the requests already loaded and more. ⌘1 to ⌘9 switch pages, ⌘R now refreshes the data of the current page, and ? outside a text field shows the keyboard shortcuts.
Menu bar: the running time under In Progress in the menu is now computed by core, so a difference between the clocks of the two machines no longer affects it when the app is connected to a remote core. In the English interface, the text in the Quota and Today parts of the macOS menu bar menu no longer overlaps, and the tray on Windows and Linux names the local connection This computer.
-
ThinkWatch Core v0.49.0 GitHub ↗
This release states how far back the route and rule hit counts reach, and reports the signed-in account in the status of a ChatGPT sign-in. Release pages now begin with a summary of the release and list the downloads for each platform with the commands for a server installation.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) is now 22. ThinkWatch Lite connects only to a core with the same protocol version, so a server has to run the core version that the app includes. ThinkWatch Lite 2026.9.16 includes 0.47.0 (protocol 20) and does not connect to 0.49.0: a server used with it stays on 0.47.0 until the app is updated to a release that includes 0.49.0.sudo twcore upgrade --version 0.47.0 --restartswitches a server back to 0.47.0. GET /summary/routesreturns an object,RouteStats { covered_since_ms, routes }, instead of an array ofRouteHits;routesholds what the array held.ChatgptLoginStatus.planis removed.ChatgptLoginStatus.accountcarries the email and the plan.- The request store’s schema is unchanged (20), so 0.48.0 and 0.49.0 keep each other’s request history. Upgrading from 0.47.0 or an earlier version empties the request history on the first start, as 0.48.0 does.
How far back the hit counts reach.
GET /summary/routescounts the stored requests, so its counts reach back only as far as the stored history. On a new installation, after an upgrade that emptied the request history, or when requests are kept for fewer days than the window, the oldest stored request is later than the start of the window, and before it a rule without hits is unknown rather than unused.covered_since_msis the later of the window’s start and the time the oldest stored request started; when it is set, it lies within the window. It isnullwhen the stored history covers no part of the window: the store holds no request, or the oldest one started at or after the end of the window.The account after a ChatGPT sign-in. The status of a ChatGPT sign-in (
GET /chatgpt/login/{id}) reports the account it signed in to inaccount, with the email and the plan. It is the same block asProviderView.oauth.accountand is read from the same access token, the one the sign-in saves in the configuration, so the sign-in and the upstream report the same account.Release pages. A release is titled “ThinkWatch Core” followed by its version. Its page begins with a summary of the release when one is written, followed by a table of the files for each platform, the commands that install the version on a Linux server and switch an existing installation to it, how to verify a download against its
.sha256, and the list of pull requests. The pages of 0.47.0 and 0.48.0 carry the same sections.Documentation. The README describes installing the prebuilt binaries, the Linux install script and running
twcoreas a service on a server, and lists all of the crates, grouped by dependency order, including the three that ThinkWatch Enterprise depends on (tw-dialect,tw-guardandtw-breaker). - The control-plane protocol version (
-
ThinkWatch Core v0.48.0 GitHub ↗
This release reports more of what core knows about each request: the session and the route from the moment a request starts, a snapshot of running requests that a client can replay, and how often each route and rule was used. It also names the account signed in on a ChatGPT account upstream, counts the requests in each cost group that have no price or no usage, and gives the security log totals for the whole window.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) is now 21. ThinkWatch Lite connects only to a core with the same protocol version, so a server has to run the core version that the app includes. ThinkWatch Lite 2026.9.16 includes 0.47.0 (protocol 20) and does not connect to 0.48.0: a server used with it stays on 0.47.0 until the app is updated to a release that includes 0.48.0.sudo twcore upgrade --version 0.47.0 --restartswitches a server back to 0.47.0. - The format of the request history changed (request store schema 20). On its first start, 0.48.0 replaces the request history and the stored request and response bodies of an earlier version with an empty store; the configuration is kept. Switching back to an earlier version empties it again.
GET /in-flightreturns an object,InFlight { now_ms, requests: [{ id, events }] }, instead of an array of start events.RequestStarted.session_fpis replaced bysession.ChatgptUsage.emailandChatgptUsage.planare removed; the signed-in account is inProviderView.oauth.account.
Session and route from the start. The gateway assigns a request to a session when the request starts (the same conversation, within 30 minutes of its previous request), and
RequestStartedcarries that session id, the one the request is stored under. The first routing phase now finishes before the start event, soRequestStartedalso carries the route, the rule, the strategy group and the rules that rewrote the request.RequestRoutedcarries the final record, including rewrites from the second phase anddenied_byfor a rule that denied the request there. The stored routing record has the same fields and is written from the start event, so a request that ends before routing completes still shows the route and rule it matched.Requests a rule decided. A request that a rule denies in the first routing phase, or that none of the upstreams a rule chose can serve, used to produce no events and no stored row. It now produces start, routed and failed events and a row, and counts among the requests and failures in
/summary; a denial in the second phase now also produces the routed event. Requests that fail before a rule decides (authentication, model admission, no matching rule) are still not recorded.Live views.
GET /in-flightreturns core’s clock and, for each running request, the events seen so far in order, so that a client connecting while requests are running can replay them and compute elapsed time from core’s timestamps. The running requests inGET /liveaddelapsed_ms,session,route,rule,groupandupstream.Rule hit counts.
GET /summary/routesreports, for a time window (today by default), each route’s requests, failures and last request, and for each rule the requests it decided, the requests it applied to, their failures and the last one. The counts come from the stored routing records, not from the current configuration.Cost groups and the security log. Every group returned by
GET /summary/buckets/byreportsunpriced_requestsandno_usage_requests, so a group with no cost can be told apart as unpriced, missing usage or free.GET /security/eventsaddstotalandby_outcome(recorded, replaced, cut, blocked): the counts for everything the query’s guard and time window match, the same on every page.ChatGPT account upstreams.
ProviderView.oauth.accountgives the email and plan of the account signed in on a ChatGPT account upstream. Both are read from the access token core already keeps with the credential: nothing is requested from the network, nothing new is stored, and the account and user ids in the token are not exposed. The plan is reported as the backend names it, including plans that core does not list.Releases. A release is now published by a single job once every platform has been built and each file matches its SHA-256. Previously the first platform to finish created the release, and made it the latest, while the others were still building, and each build job added another copy of the release notes. The server guide, docs/server.md, states that ThinkWatch Lite keeps a remote connection’s key in a private file in its data directory, and applies to the app on macOS, Windows and Linux. The crates carry homepage and documentation links, and
twcore --helpbegins with the binary’s description.Tests. Tests no longer hand core a port that was just released, which could make tests that bind ports, such as those of the remote control port, fail intermittently on Linux.
- The control-plane protocol version (
-
ThinkWatch Lite v2026.9.16 GitHub ↗
Upgrade note: the proxy configuration now rejects misspelled field names, such as
typwritten fortype. Such fields used to be ignored; after the upgrade, a configuration that contains one does not pass validation and the gateway does not start. A proxy configuration that was edited by hand should be checked before upgrading.Remote connections: the app can now connect to a core running on another machine, such as a Linux server. A remote connection is added in Settings → Connection with its address, port and key, and once the connection test succeeds the app can switch to it. Installing and configuring the server side is described in docs/server.md in the ThinkWatch-Core repository. The app connects to one core at a time: while it is connected to a remote core, the local core is stopped and its data is kept. When switching, the clients already connected on this computer can be pointed at the server in the same step.
When a connection fails, the interface remains usable. At launch the app waits up to about 8 seconds, then shows the Not connected page, where the connection can be retried, edited or switched to this computer. If the connection drops while the app is running, pages become read-only and a notice is shown once; it is removed automatically when the connection returns. The menu in the menu bar, or in the tray on Windows and Linux, has a new Connection submenu. When Option is held at launch (Alt on Windows), or after two launches in a row did not finish, the connection choice is shown first.
In remote mode: the Clients and MCP pages check and change the client configuration on this computer; Settings is divided into two groups, the app on this computer and the configuration on the server; ChatGPT accounts sign in with a device code only; the diagnostic bundle is not offered.
The control channel between the app and core now begins with an encrypted handshake, and local and remote connections use the same key, which is written in the configuration file. Connecting clients, MCP management and the client configuration scan are now carried out by the app itself.
-
ThinkWatch Core v0.47.0 GitHub ↗
This release adds a remote control port, through which ThinkWatch Lite on another machine can manage a core running on a server, together with an install script, a systemd unit and
twcore upgradefor runningtwcoreas a service on Linux. ThinkWatch Lite 2026.9.16 includes this version.Upgrade notes
- A misspelled field under
proxies(for exampletyp:fortype:) is now a configuration error; it used to be ignored. A configuration that contains one does not passtwcore check, and core does not start with it. - Every connection to the control plane now begins with a Noise handshake, and control requests no longer use a bearer token. HTTP clients such as curl cannot call the control plane directly;
twcore callsends a request through the handshake. - Client adoption, MCP editing and the client configuration scan moved to ThinkWatch Lite, which runs on the machine where those files are. The
twcore scanandtwcore clientssubcommands and the related control-plane endpoints and events are removed;POST /clients/{id}/keyremains.
Control key.
listen.control.keyinconfig.yamlholds the key, andtwcore serveadds one to a configuration that has none. Every control transport (the unix socket, the loopback port on Windows and the remote port) starts with aNoise_NNpsk0_25519_ChaChaPoly_BLAKE2shandshake keyed by it, and a wrong key is refused before any HTTP.twcore control-keyprints the key;--rotatereplaces it and closes the connections made with the previous one. The key is masked inGET /config, in the configuration history and in diagnostic bundles, and it cannot be changed through the control plane.Remote control port.
listen.control.remoteopens a TCP port for ThinkWatch Lite on another machine, in addition to the local channel.twcore initwrites the section disabled, with a random port from 20000–32000.twcore remote enable [--bind B] [--port N] [--allow CIDR],twcore remote disableandtwcore remote showchange and report it, and a running core applies a change within a second. A source outsideallow_from(the private ranges by default; loopback is not added automatically) is closed before the handshake, and a source with five failed handshakes within 60 seconds is ignored for 60 seconds. Remote connections cannot shut core down, download diagnostics or changelisten.control.Server deployment.
scripts/install.shinstallstwcoreon Linux (x86_64, aarch64) as a systemd service, with athinkwatchsystem user, data in/var/lib/thinkwatchand environment variables in/etc/thinkwatch/env; it checks the SHA-256 and never starts or restarts the service. Releases now includetwcore-<target>.tar.gzfor both Linux targets, holding the binary,twcore.serviceandLICENSE.twcore upgrade [--check] [--restart] [--version X.Y.Z]replaces a separately installedtwcorewith a release from GitHub after checking its SHA-256 and running it once; it does not replace the copy inside ThinkWatch Lite, which the app updates itself. The guide is docs/server.md.Configuration reference. docs/config.md (and
docs/config.zh-CN.md) describes every section and field ofconfig.yaml. The field tables and the lists of built-in rules are generated from the code, and a test fails when the manual and the code disagree.Fix. On Linux, changing
listen.gateway.bindbetweenalland a single address on the same port now takes effect without a restart; it used to be reported as a port in use by another program. - A misspelled field under
-
ThinkWatch Lite v2026.9.15 中文 GitHub ↗
升级须知:请求记录会清空。上游请求头中如写有 {{client}},升级前请先删除,该写法已不再支持,否则配置无法通过校验、网关不会启动。
新增 Linux 版本(x86_64 与 aarch64 的 AppImage),支持应用内更新。可用一行命令安装:curl -fsSL https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest/download/install.sh | sh
安全页新增三项防护:请求中的隐藏字符(标签字符、双向控制符)、内容规则(内置规则可逐条启停和改处置,另可添加「包含」或「正则」自定义规则)、输出长度上限。三项均可选择关闭、仅记录或拦截,命中会记入安全日志、请求详情和概览。
安全加固:转发时不再跟随上游的重定向,避免上游凭据被带到其他地址;WebSocket 请求中的网关密钥不再发往上游;配置、请求记录等含密钥的文件在创建时即只允许本人读写;工具调用审查可识别更多 SSE 写法;窗口权限按需收紧。
提醒改为按当前状态核对:应用启动或重新连接网关时,已存在的凭据失效、配置未通过校验等问题会补发提醒,已解决的会自动收起,同一问题不会重复提醒。系统通知和界面中的错误原因均显示为中文。
其他:重放请求与正常转发使用同一出站设置;菜单栏的进行中请求和生成速率改由网关统计;网关已在运行时重新打开窗口不再播放启动动画;上游表单的请求头列表第一行显示密钥所用的认证头。
-
ThinkWatch Enterprise v2.0.0 GitHub ↗
Callers now get errors in their own API’s format, and an upstream that refuses a request no longer takes a model’s other routes down with it. The gateway also speaks two more client protocols: Gemini, and the Responses API over a WebSocket. Cached input is billed at cache prices, and a request with no usage report is billed on an estimate instead of at zero. The TOTP requirement, which never took effect before, is now enforced. This is a major release because error bodies, the content-filter preset ids and the TOTP behaviour all change in ways a client or a script can notice.
Read before upgrading#
-
Check
security.totp_requiredbefore you upgrade. In 1.x this setting never took effect: it is stored as a boolean and was read as a string, so it always read as off. From 2.0.0 it is enforced by the server. Find out what it is set to:SELECT value FROM system_settings WHERE key = 'security.totp_required';If it is
true, every console user without TOTP, super admins included, is held at a TOTP setup screen on their next request, and sessions that are already open are held too. Until they set up TOTP, every console and admin endpoint answers 403totp_enrollment_required, except/api/auth/me, logout,register-keyand the TOTP status/setup/verify-setup calls. Setting up TOTP releases the session straight away. API keys are not affected: gateway, MCP and consoletw-key traffic keeps working. While the setting is on,POST /api/auth/totp/disableis refused with 400. The setting must now be a JSON boolean; a string such as"true"is refused on save. -
Gateway error bodies follow each client API’s own format. Status codes and
Retry-Afterare unchanged. Code that reads the errortypeneeds updating:- Chat Completions and Responses keep the
{"error": {"message", "type", …}}shape, buttypeis now OpenAI’s value for the status, not a ThinkWatch tag:authentication_error(401),permission_error(403),not_found_error(404),rate_limit_error(429),invalid_request_error(other 4xx) andserver_error(5xx). The old tags are gone:rate_limited,policy_blocked,provider_http_error,provider_error,provider_timeout,transform_error,network_errorandauth_error. A policy block is nowpermission_errorwith status 403. - Anthropic Messages clients get Anthropic’s body,
{"type": "error", "error": {"type", "message"}}, with Anthropic’s type names (rate_limit_error,overloaded_error,api_error, …). - Gemini clients get Google’s body,
{"error": {"code", "message", "status"}}. - Once a stream has started, a Responses client gets a
response.failedevent, where before it got a Chat-style error frame that SDKs skip, so the stream just stopped. An Anthropic client gets anerrorevent whose type follows the status. - The
error_typefield ingateway_logsand the metric labels are unchanged.
- Chat Completions and Responses keep the
-
Only upstream failures fail over or count against a route’s breaker. In 1.x every non-2xx except 401, 403 and 429 was retried on the model’s other routes and counted as a failure on each of them, so one malformed request could open the breakers on all of a model’s routes.
- Tried on another route and counted against this one: 5xx, 408, 429, 401 and 403 (the upstream refused the gateway’s own credential), timeouts, network errors and unreadable responses.
- Returned to the caller straight away, and counted as the upstream working: every other 4xx.
- What the caller sees: such a 4xx comes back with its own status and the upstream’s reason. In 1.x it came back as a 502 after every route had been tried.
- An upstream 5xx comes back with the upstream’s status (500, 503, …) rather than a blanket 502.
- An upstream timeout is now 504.
- Streams follow the same rule. A stream cut by tool-call inspection no longer counts against the route.
-
Cached input is billed at cache prices. In 1.x cache reads and writes were billed, and debited from budgets and weighted rate limits, as full-price input.
modelsgains three weights,cache_read_weight,cache_write_weightandcache_write_1h_weight. When a weight is unset, it isinput_weighttimes Anthropic’s ratio: 0.1× for a read, 1.25× for a write and 2× for a one-hour write. What this changes:- Traffic with many cache reads (Claude Code, for instance) costs much less than it did.
- Traffic that writes to the cache costs a little more.
- Older OpenAI models discount cache reads less (0.5× or 0.25×). Set the weights on those models yourself.
input_tokensin the log is still the whole input. The log detail gainscache_read_tokens,cache_write_tokensandcache_write_1h.
-
Output length limits now apply to streams. In 1.x,
max_lengthoutput guardrails checked only whole responses, so streamed answers were never checked. Now the frame that would cross the limit is not sent, and the stream ends with an error in the caller’s format. A response served from the cache is also checked against the limit in force. If you set a limit, streamed answers that used to go through can now be cut off. -
Content-filter preset groups are renamed. The groups are now
injection,personaandchinese; they used to bebasic,strictandchinese. This matters only if you call the presets endpoint by group id. Rules you have already added are copies and are not affected. Other changes to the filter:- A rule with an empty pattern is now refused on save.
- Each text part of a message is scanned separately, so a pattern no longer matches across two parts.
- The engine is now shared with ThinkWatch-Core’s
tw-guard. The stored format and the admin API are unchanged.
-
Requests with no usage report are billed on an estimate. In 1.x such a request was billed at zero. This happens when an upstream ignores the request for usage, or when the caller leaves before the final chunk arrives. The estimate is:
- input: about four bytes of the request per token, not counting images and files;
- output: the answer that actually arrived.
Estimated rows carry
usage_estimated: truein their detail and count ingateway_usage_estimated_total. A request with no answer at all is still billed at zero. -
Clients that leave early are now logged. In 1.x a client that disconnected before its response existed left no
gateway_logsrow at all. That covers leaving during auth, limits or routing, or while waiting for a whole (not streamed) answer. Such a request now writes one row: status 499,stream_outcome: client_cancelled,cancelled_before: response, no tokens and no cost. Expect more 499 rows in dashboards and log forwarders. A new counter,gateway_cancelled_before_response_total, counts them.
Database changes#
Both apply on their own at startup, as every schema change does, and both are additive:
modelsgains three nullable columns:cache_read_weight,cache_write_weightandcache_write_1h_weight(ALTER TABLE … ADD COLUMN IF NOT EXISTS,CHECK (>= 0)).system_settingsgets anauth.default_rolerow, seeded empty (no role). Existing rows are left alone (ON CONFLICT DO NOTHING).
Neither is irreversible. A 1.1.0 server runs against the upgraded database: it ignores the new columns and the new setting. What a 1.1.0 server cannot do is price cache tokens from the weights.
Added#
-
Gemini clients. New endpoints:
POST /v1beta/models/{model}:generateContentand:streamGenerateContent, also served under/v1/models/…;GET /v1beta/models, which lists models in Gemini’s format.
These requests get the same limits, budgets, filters, routing with failover, format conversion, inspection, billing and audit as every other endpoint. A Gemini upstream gets the request as it was sent. A stream comes back as SSE with
alt=sse, and as Gemini’s JSON array without it.:countTokensand:embedContentare refused with 400. -
The Responses API over a WebSocket. Connect to
GET /v1/responseswithUpgrade: websocket.- Each
response.createframe is handled like a streamedPOST /v1/responses, with its own limits, routing, billing and audit row. - Turns on one connection run in order. A refused turn fails with
response.failed, and the connection stays open. - The connection keeps its latest response. That lets a turn continue
from it with
previous_response_id, even withstore: false, which is how Codex works, and against any upstream format. - A new counter,
gateway_responses_ws_connections_total, counts connections.
- Each
-
More places to put an API key. Gateway keys are also accepted in
x-api-key(Anthropic SDKs),x-goog-api-keyand?key=(Gemini SDKs), as well asAuthorization: Bearer. Headers are checked first. A key given in the query string is never sent upstream. -
Hidden-text audit events show what the text says. Each item in
foundgainsrevealed, the ASCII that the hidden tag characters spell. -
auth.default_rolecan be set. It is the role that newly registered users and SSO users get. In 1.x, setting it through the admin API reported success but changed nothing, because the setting row did not exist. -
Model editor fields for the three cache weights. Each placeholder shows the value used when the field is left empty.
Changed#
- Default output length for upstreams that require
max_tokens. When the caller sets none, the gateway now sends 32000 for Claude models and 8192 for other models. It used to send 4096, which cut Claude answers short. - More upstreams count as the vendor’s own endpoint. DeepSeek,
Moonshot, Zhipu/Z.ai, DashScope, xAI and
*.amazonaws.comare now recognised, and the check reads the parsed host. A relay URL such ashttps://relay/api.openai.comno longer passes as official. Official endpoints are stricter about request parameters, so the gateway drops or renames some parameters before sending to them. - Hidden-text scanning uses ThinkWatch-Core’s
tw-guard. Same scope, same actions, and nothing is stripped. - Requests forwarded in their own format lose ThinkWatch’s reasoning
signatures. A
tw1.signature written by an earlier format conversion is removed, because Anthropic rejects it. The upstream’s own signatures are kept. - Core crates:
tw-dialect,tw-guardandtw-breakerat ThinkWatch-Core v0.43.0. The code only this edition used (at-rest crypto, SigV4 signing, the gateway error type) moved into this repository. It works the same, and stored secrets decrypt as before. - The server’s SQL moved from the request handlers into repository modules (catalog, dashboard, limits, log forwarding, identity, access, MCP). Every statement is unchanged. New integration tests cover these endpoints and pass on both the old and the new code.
- CI runs on pull requests into
dev, including the whole integration suite against Postgres, Redis and ClickHouse.
Fixed#
- Revoking a user’s default MCP connection always failed with a 500, and the account could not be revoked. The newest remaining account is now made the default.
-
-
ThinkWatch Enterprise v1.1.0 GitHub ↗
The gateway stops rebuilding every request as a chat-shaped message. A request whose route speaks the caller’s own format goes out as the caller sent it; one that crosses formats is converted by ThinkWatch-Core, the same layer the desktop edition uses. Tools, tool choice, system prompt blocks,
metadataandcache_controlnow reach the upstream, where they used to be dropped. Two new checks guard what goes in and out: tool calls an upstream returns, and invisible characters in what a caller sends.Read before upgrading#
- Anthropic routes record more prompt tokens for the same work.
Prompt tokens now count the same for every upstream: plain input plus
cache reads and cache writes, which is OpenAI’s definition.
Anthropic’s own
input_tokensleaves the cached part out, so on those routes prompt tokens, cost and budget use all go up. The price model still charges every prompt token at one rate. - Requests in the upstream’s own format are forwarded as sent. Every
field the caller sends reaches the upstream, along with the caller’s
anthropic-betaandanthropic-versionheaders. Only the model name changes, and PII is swapped for placeholders. An OpenAI-compatible upstream that rejects fields it does not know may now refuse requests that used to succeed, because those fields were stripped before. Try your upstreams with the clients you actually run. - Two checks are on by default, and neither blocks anything yet.
- Tool-call inspection starts in
observemode. - The hidden-character check starts in
warnmode. - Both write audit events, so expect new entries in the audit log and in anything subscribed to it.
- Nothing is refused until you switch to
enforceorblock.
- Tool-call inspection starts in
- The response cache starts cold. The cache key now covers the whole request, so entries written by 1.0.2 are never hit again. They expire on their own.
- One PII value gets one placeholder. Within a request, the same
e-mail address is
{{EMAIL_1}}wherever it appears. It used to get a new number each time, so a model saw one person as several. Saving a PII pattern now also requires the placeholder prefix to be letters, digits or underscores.
Added#
- Tool-call inspection (
security.tool_inspection).- Why: an upstream writes the response, so it can hand the caller
a tool call the model never made, such as
bash("curl … | sh")appended to an ordinary answer. An agent set to auto-approve then runs it. - Rules: a built-in set of dangerous-command rules. Each can be switched off or given a different action, and you can add your own.
- Modes:
off,observe(records hits and changes nothing on the wire) andenforce. - Enforce on a stream: the stream is cut at the frame that would complete a matching call, and the refusal arrives in the caller’s format.
- Enforce on a whole response or a cache hit: refused with 403
(
policy_blocked). A refused answer is neither cached nor billed. - Audit and metrics: every hit is an audit event
(
gateway.tool_call_flaggedorgateway.tool_call_blocked) and counts ingateway_tool_call_flagged_total. - Admin API:
GET /api/admin/settings/tool-inspection/rulesandPOST /api/admin/settings/tool-inspection/test. - Console: a card on the security page, plus a sandbox tab.
- Why: an upstream writes the response, so it can hand the caller
a tool call the model never made, such as
- Hidden-character check (
security.hidden_text:off,log,warnorblock).- What it looks for:
- Unicode tag characters, which carry an instruction invisibly into the model’s context;
- bidirectional overrides, which make text read differently on screen than it is.
- Where: the caller’s messages and the tool results inside them. The system prompt and the model’s own turns are not checked.
- Not flagged: zero-width joiners (emoji), the zero-width non-joiner (Persian) and Cyrillic.
- Actions:
warnwritesgateway.hidden_text_flagged;blockrefuses with 403 and writesgateway.hidden_text_blocked. - Console: a card on the security page.
- What it looks for:
- Tool-call arguments get their PII back. A model asked to e-mail
a@example.comused to call the tool with{{EMAIL_1}}as the address.
Changed#
- One pipeline for
/v1/chat/completions,/v1/messagesand/v1/responses. A same-format request is forwarded as sent. A cross-format request is converted, and the gateway logs what the target format cannot carry. - Content filtering and PII detection read tool results too. That is where an injected instruction, or customer data pulled in by a tool, usually sits.
- Usage is read off the upstream’s own bytes. A streamed response is no longer held in memory for an accounting pass at the end.
- Chat streams are billed on the upstream’s actual usage. They are always sent asking for it. A caller who did not ask for the usage chunk still does not get one. 1.0.2 estimated the count for these.
- Streams send their headers at once. A caller who leaves while the upstream is still thinking is recorded as cancelled.
- Connectivity tests use the live encoder. A route’s test request is built by the same encoder as real traffic, so a passing test means forwarding works.
- The web console loads data through TanStack Query.
- Signing out, including from another tab, clears everything cached.
- After a change, screens refresh in place.
- Polling pauses while the tab is hidden.
- Core crates come from one pinned tag (ThinkWatch-Core v0.40.0), declared once at the workspace root.
Fixed#
- Requests lost their tools, tool choice and non-text content on the
way upstream (ThinkWatch-Core#50). Claude Code’s system prompt, sent
as an array, was dropped whole, and so was every
cache_controlbreakpoint. Each cached prefix was billed as full-price input. - The response cache could serve the wrong answer. Its key covered
only model, messages and
max_tokens, so two requests that differed only in tools,response_format,seedand so on shared one entry. - A tripped route stayed out until its Redis key expired, roughly four cooldowns. It now gets probed once the cooldown is over, and a success closes it.
- The dashboard showed every AI provider’s breaker as
Closed. It now shows the real state of the provider’s routes, reporting the worst one. - The PII “try patterns” endpoint misreported labels. A pattern
named
CUSTOM_EMAILwas reported asCUSTOM. :latestcould point at amainbuild rather than the release, which is what happened for v1.0.2’s server image. Only the release workflow sets:latestnow.- The web image hung for six hours. Its frontend was built under QEMU for arm64, where Node crashed and the step never returned. It is now built once, natively. The image contents are unchanged.
Security#
- Refreshed the web console’s lockfile to clear 56 Dependabot alerts (1 critical, 23 high). All were transitive, and none of them reached the shipped bundle.
- Anthropic routes record more prompt tokens for the same work.
Prompt tokens now count the same for every upstream: plain input plus
cache reads and cache writes, which is OpenAI’s definition.
Anthropic’s own
-
ThinkWatch Lite v2026.9.14 中文 GitHub ↗
上游的 API 密钥和请求头中的 ${变量名} 现在可以读取用户配置的环境变量。macOS 上读取登录 shell 中的变量,~/.zshrc 等文件中 export 的变量都会生效,此前从程序坞或访达打开时读不到;Windows 上每次启动网关都重新读取系统设置中的环境变量。代理相关的变量和 PATH 不会被读取。修改变量后重新打开应用即可生效。
上游表单中,API 密钥与请求头的说明改为写明读取系统环境变量,请求头可一键插入网关密钥名称和 Access Token。
熔断改用与企业版共用的状态机,行为不变:连续失败三次后熔断 60 秒,再放行一次探测;所有上游都不可用时照常放行,只有一个上游时不熔断。
-
ThinkWatch Lite v2026.9.13 中文 GitHub ↗
自动检查发现新版本时改为发送系统通知,点按通知后再打开更新窗口,不再自行弹出。更新窗口只显示版本号,不再显示更新说明。
-
ThinkWatch Lite v2026.9.12 中文 GitHub ↗
Windows:网关监听选择「局域网」时,可以选用系统中的网卡(如「以太网」),此前保存会失败。快捷键改用 Ctrl(Ctrl+F 搜索、Ctrl+B 收起源列表),字号与字体按 Windows 调整,「访达」「菜单栏」等说法改为 Windows 对应的名称。安装版只运行安装目录中的网关,「关于」中显示的路径也已修正。
开机启动的说明改为一句话,直接写明开启后的效果。
-
ThinkWatch Lite v2026.9.11 中文 GitHub ↗
修复 2026.9.10 中所有修改操作都会失败的问题:保存设置、新建或编辑上游、接管客户端等操作会提示控制面需要凭据,本版恢复正常。
Windows:启动时不再弹出网关的命令行窗口;客户端页中的配置文件路径改为 Windows 的写法,Zed 的配置位置以及 Continue、Gemini CLI 的手动配置步骤按 Windows 给出。
报错信息改为按界面语言显示,系统或外部服务返回的原文除外。
-
ThinkWatch Lite v2026.9.10 中文 GitHub ↗
首次提供 Windows 版,分 x64 与 arm64 两个安装程序,在发布页下载。Windows 版未做代码签名,首次运行时 SmartScreen 会拦截,选择「更多信息」→「仍要运行」即可。
新增 Z.ai / BigModel 账号登录:在上游的服务类型中选择「Z.ai / BigModel 账号(登录)」,在浏览器中授权后,账号下会生成一把 API 密钥并写入这个上游。
客户端页中的 Codex 一行同时对应 Codex 命令行和 ChatGPT 桌面应用内置的 Codex,二者共用同一份配置;接管时一并说明,ChatGPT 桌面应用需要重启才会生效。
修复:更新窗口底部的按钮被窗口边缘截断;在接管对话框中展开「完整改动」后,内容超出对话框。
-
ThinkWatch Lite v2026.9.9 中文 GitHub ↗
升级须知:2026.9.8 及更早的版本无法在应用内安装这一版,需从发布页下载磁盘映像(DMG)手动安装一次,此后的版本可照常在应用内更新;通过 Homebrew 安装的执行 brew upgrade 即可。
推理测速支持全部类型的上游。ChatGPT 账号上游此前无法测速,只会返回一句英文提示,现在与其余上游一样发送探测请求并测量首 token 时间,失败原因按界面语言显示。ChatGPT 账号不接受输出上限,费用预估中的上限显示为「不限」、金额显示为「按实际用量」,合计随之标为无法计算;会推理的模型在探测时留出推理所需的输出额度,避免上限过小导致没有可见的输出。
发布内容只保留磁盘映像及其校验值,应用内更新改为下载与手动安装同一份磁盘映像。
-
ThinkWatch Lite v2026.9.8 中文 GitHub ↗
升级须知:升级后本机的请求记录会清空。部分旧设置项已删除,配置文件中仍有这些项时网关无法启动,应用会进入安全模式;删除这些项后重新启动即可恢复。用 ChatGPT 账号登录的上游写入的「billing: subscription」属于这一类。
界面支持英文,默认跟随系统语言,也可在「设置」中选择;菜单栏、系统通知和错误信息使用同一种语言。外观可选浅色、深色或跟随系统。
导航重新整理为概览、流量、客户端、密钥、上游、路由、安全、MCP 和设置。
流量页将请求与会话合为一张表,可按会话归组;每条请求标出发出它的密钥、应用和来源机器,请求详情中的 JSON 会格式化显示。概览的实时图显示最近十分钟,切换页面后保留所选的时间范围。
路由页以密钥为中心重新设计,规则按顺序编辑;试算会给出请求使用的路由,不经过任何规则的请求也会如实说明。
密钥单独成页,显示完整密钥并可复制,标出由哪个客户端接管生成,可安全地更换;每把密钥可设置可见模型和并发上限,全局并发限制已删除。客户端页用一张表列出各客户端的状态和所用密钥,未检测到的客户端也给出手动配置方法;「全部还原」此前只还原 opencode,现在会还原所有已接管的客户端。
安全页只保留出站脱敏和工具调用审查两项全局防护,匹配规则可查看和自定义,并新增安全日志;MCP 扫描单独成页。
计费方式只分按量计费和不计费,ChatGPT 等订阅账号也按价目表计算费用。上游行可直接展开模型列表,缺失的列表会自动获取;ChatGPT 账号上游显示邮箱和套餐,也可在另一台已登录的设备上输入代码完成登录。
菜单栏改为原生样式,显示今日 token 和今日费用,菜单中可查看额度、进行中的请求和提醒。提醒只保留一个总开关,可标为已读;不再监测磁盘空间。
网关监听的访问范围、网卡、端口与放行网段移入「设置」,保存后生效;日志保留时长也可在「设置」中调整。启动时显示启动画面,网关就绪后进入主界面,各页面随数据变化实时更新。
-
ThinkWatch Lite v2026.9.6 中文 GitHub ↗
可以用 ChatGPT 账号新建上游:在浏览器中完成登录后,订阅额度即可供各类客户端使用。登录前会说明相关风险;账号的额度与额度重置卡在该上游的菜单中查看和使用。
不同格式之间的请求可以互相转换:使用 Anthropic、OpenAI Chat、OpenAI Responses 或 Gemini 格式的客户端,可以使用其中任何一种格式的上游。请求详情中会显示转换情况与未能保留的字段。
新增提醒:订阅额度用完、登录失效、上游拒绝凭据、代理不通、磁盘空间不足等情况记录在工具栏的提醒列表中,需要处理的会弹出系统通知。每一类提醒可在「设置」中选择弹出通知、仅在应用内显示或关闭。
上游页重新设计:上游、代理与价目表分为三个标签,新建与编辑均在对话框中完成;上游可以设置自定义价目表与额外的请求头。
请求列表会标出被取消的请求、部分失败的请求与缺少用量的请求。
-
ThinkWatch Enterprise v1.0.2 GitHub ↗
The shared gateway layer moves out into its own repository, and OIDC learns to accept an ID token whose
audcarries more than the client ID. Mostly a fix release otherwise — the deploy and auth items below are the ones worth reading before you upgrade.Added#
OIDC_ADDITIONAL_TRUSTED_AUDIENCES— a comma-separated allowlist of extra ID-token audiences to trust. Some IdPs put something besides the client ID inaud; Zitadel includes the parent project ID, andopenidconnect’s stock verifier rejects every non-client-ID audience, so login failed outright. Leave it unset and verification is byte-for-byte what it was. Set, it widens exactly one check: the extra audiences on a multi-audience token are matched against this list as exact strings — no wildcards, no prefixes. The client ID must still appear inaudregardless. Documented in.env.example. Thanks to @DaniW42 for the report and the patch (#12).- Automatic upstream protocol detection — the gateway learns an upstream’s dialect and relearns it when the upstream changes, instead of relying on a static guess. Protocol is now a property of the route.
- Reverse-proxy identification — the server recognizes its own reverse proxy, which is what makes the dashboard WebSocket and the API docs work behind nginx.
Changed#
azpis validated whenever it is present, per OIDC Core 3.1.3.7 step 5, which has no audience-count precondition. Previously it was only checked when a multi-audience token made it mandatory, so a single-audience token naming this client withazppointing at a different client was accepted — the IdP stating plainly that the token was authorized for somebody else. This can reject a token that 1.0.1 accepted. If your IdP setsazpto something other than your client ID, logins will start failing and the message will say so; that is the IdP to fix, not this setting.- The shared gateway layer now comes from ThinkWatch-Core as a git dependency rather than living in this tree. Six crates — types, protocol, provider, resilience, crypto, and the JSON secret envelope — are one implementation shared with the desktop edition instead of two copies drifting apart. No runtime or API change; it affects you only if you build from source, where the build now needs network access to resolve that dependency. The published images are unaffected.
- Stored secrets are redacted on read and decrypted on use. Provider credentials no longer travel in plaintext through code paths that merely display or list them.
Fixed#
- Deploy — nginx broke the API docs three separate ways; the dashboard WebSocket wasn’t proxied; the prod stack’s healthchecks were wrong; and the dev stack could overwrite production’s ClickHouse credentials. The last one is the reason to read this list.
- Auth — the console logged itself out after every token refresh. Rate-limit counters could be created without an expiry and sit in Redis forever.
- Models — the gateway no longer offers models the upstream refuses,
no longer imports models it won’t serve, and reports every dialect
that was refused rather than only the last. Provider
base_urltrailing slashes are normalized. - Analytics — spend is attributed to a person, not a UUID.
- Audit — a syslog forwarder formatting fix that had CI red on
clippy 1.98 (#11, thanks @DaniW42). Two more instances of the same
lint, plus a
result_large_errfalse positive in the MCP lifecycle stages, measured rather than boxed: theOkvariant is 296 bytes against theErr’s 152, so boxing saves nothing and adds an allocation. - UI — the brand mark stays inside the collapsed sidebar rail.
Contributing#
CONTRIBUTING.mdand a PR template now state the branch contract that had only lived indocs/operations/release.md: routine work targetsdev, andmainis the release line. A workflow comments on PRs opened againstmainfrom anything other thandevor ahotfix/*branch, because GitHub pre-fills the base with the default branch and walks contributors into it.Added#
- (nothing yet)
Changed#
- (nothing yet)
Fixed#
- (nothing yet)
Removed#
- (nothing yet)
Security#
- (nothing yet)
May 2026
-
ThinkWatch Enterprise v1.0.1 GitHub ↗
Release-pipeline validation. No product change — the published binary, REST surface, MCP wire shapes, and audit semantics are identical to
v1.0.0. Operators pinning1.0.0have no reason to bump; those tracking:latestmove forward.Changed#
- Release workflow — arm64 image builds now run on a native
arm64 runner (
ubuntu-24.04-arm) instead of QEMU emulation. v1.0.0’s server image build took 1h24m; this should drop to ~10 min. Multi-arch manifest assembled by a new merge job viadocker buildx imagetools create. - Node 24 opt-in — workflow sets
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=trueso allactions/*+docker/*run on Node 24 ahead of GitHub’s 2026-06-02 forced cutover.
- Release workflow — arm64 image builds now run on a native
arm64 runner (
-
ThinkWatch Enterprise v1.0.0 GitHub ↗
Stability commitment. No code delta since
0.5.0— this tag marks the point at which the API surface becomes a SemVer commitment.Changed#
- Versioning policy — from this tag onwards every breaking
change (REST routes, MCP wire shapes, audit-row JSON keys,
database schema, public Rust APIs in published crates) requires
a major bump. Operators chasing the
:latesttag on the GHCR images can do so without surprise.
Notes#
- Docker images cut at this tag receive
:latestfor the first time — the release workflow suppresses:lateston0.xand pre-release tags. Pin the version in production rather than tracking:latestunless you have a controlled rollback path.
- Versioning policy — from this tag onwards every breaking
change (REST routes, MCP wire shapes, audit-row JSON keys,
database schema, public Rust APIs in published crates) requires
a major bump. Operators chasing the
-
ThinkWatch Enterprise v0.4.0
See every byte
- Request + response bodies captured for every gateway and MCP call
- PII redaction at capture; per-column TTL splits hot bodies from cold metadata
- S3-compatible body offload — AWS S3, MinIO, Ceph, or the bundled RustFS
- Bloom-filter substring search across captured bodies
- audit:read_bodies permission gates the body viewer
- MCP downstream SSE — clients can subscribe to tools/call event timelines
- Per-model routing with Auto / Manual modes + drag-to-redistribute traffic bar
- Model-level kill switch — pause one model without the whole provider
- Pre-call budget check rejects requests when caps are already exhausted
- allowed_models enforced uniformly on Anthropic Messages + OpenAI Responses
Full-body audit capture#
ThinkWatch positioned itself as a bastion for AI traffic — but every audit row was metadata-only. Token counts, latency, cost. No record of what the user actually asked or what the model actually answered. Compliance teams couldn’t replay incidents, attest “PII X was sent”, or even tell two near-identical requests apart.
v0.4.0 closes the gap.
gateway_logsnow persistsrequest_body+response_body(with byte counts and abody_capture_statusdimension);mcp_logspersists the paralleltool_arguments+tool_result. ZSTD(6) compression keeps cold storage tractable; six dynamic config toggles (capture toggles per field + a globalbody_max_bytes) let operators dial scope without a redeploy. Cache-hit responses now emit a full audit row instead of disappearing into a hole.Per-column TTL + S3 offload#
Hot evidence and cold metadata have very different retention shapes. Per-column TTL lets you keep the last 30 days of bodies and several years of token/cost rows in the same table, without paying ClickHouse for the cross.
For payloads that exceed
body_max_bytes— 200k-context prompts, MCP file-read tools returning whole files, base64 images — bodies offload to S3 instead of being truncated. Backend is config-driven via six env vars (S3_ENDPOINT_URL,S3_BUCKET, etc.) and works against AWS S3, MinIO, Ceph, or the bundled RustFS container shipped in the dev and prod Docker Compose files. Object keys carry a date prefix (bodies/<table>/yyyy/mm/dd/...) so bucket lifecycle rules can age them off independently. The body-viewer endpoint transparently dereferencess3://cells, so the console doesn’t know offload exists.Bloom-filter substring search#
Captured-body columns ship with a
tokenbf_v1(512, 3, 0)index. Auditors hunting for “which conversations mentioned API key XYZ” or “which tool calls touched /etc/passwd” no longer scan every granule — the bloom filter rejects the vast majority of rows up front. Indexes are sized at ~50 KB per granule, paying for themselves the first time someone needs to answer a real compliance question.A new
audit:read_bodiespermission gates the body viewer in the console and the underlying/api/admin/{gateway,mcp}/logs/{id}/bodyendpoint, so the raw payload is preserved as legal record of intent while still requiring a separate grant to read.MCP downstream streaming#
tools/callwas buffered-only on the client side: even when the upstream emitted progress notifications, the gateway swallowed the timeline and returned a single JSON. v0.4.0 negotiates on theAcceptheader — clients includingtext/event-streamreceive each upstream event (notifications/progress, partials, final response) as a discrete SSE event downstream. JSON-only clients keep the buffered shape for backward compat. The audit pipeline still gets the full event timeline either way.Per-model routing#
Model-route configuration is now organised around the admin’s actual mental model: Auto (latency-cost, balanced, or latency-only) or Manual (a drag-to-redistribute traffic bar across peers). The priority-tier failover concept is gone; replaced with nginx-style flat peers + circuit-breaker bypass. Each model can override the global strategy; the dashboard surfaces
routing-projectionso the UI can show “your manual config will cost $X; auto would cost $Y” before you commit.Three new endpoints back the UI:
PATCH /api/admin/model-routes/batch-weights— the drag bar’s commit endpointGET /api/admin/models/{id}/routing-projection— current vs. auto split + projected $/1M tokensGET /api/admin/models/{id}/route-history— 60 one-minute p50/p95 buckets for the latency sparkline
The decision log captures provider chosen + reason + any fallback for every request, and every
gateway_logsrow stamps the actualupstream_modelso post-mortems are unambiguous.Pre-call budget enforcement#
Budget caps used to fire post-flight: a runaway client whose monthly cap was already at zero could still fire requests, and only the post-call debit would notice — by which point the upstream had already burned tokens. v0.4.0 adds a
check_budgetlifecycle stage that runs before the upstream call. Steady-state “your cap is exhausted, stop sending” is now blocked at the gate; the existing crossing alerts continue to cover the concurrent-burst race.allowed_models on every API surface#
The per-API-key
allowed_modelsallowlist is now enforced uniformly on OpenAI Chat Completions, Anthropic Messages, and OpenAI Responses. The same enforcement is wired into the shared lifecycle pipeline, so future surfaces inherit it automatically — no API surface bypasses the model allowlist.Under the hood#
A shared lifecycle pipeline unifies the AI gateway and MCP gateway request paths behind a common
Surfacetrait.check_limits,check_access,check_budget,record_usageare now stages applied symmetrically on both surfaces, eliminating the historical drift between buffered, streaming, and short-circuit branches. The MCPproxy.rsand gatewayproxy.rsboth got split into submodules, and the 1667-line dashboard component on the frontend was carved into focused subcomponents. -
ThinkWatch Enterprise v0.3.0
MCP, the per-user way
- Per-user upstream credentials — every developer authenticates as themselves to GitHub, Linear, Slack
- One-paste OAuth onboarding via Dynamic Client Registration
- MCP Store with bilingual templates (Linear OAuth seeded out of the box)
- Step-by-step registration wizard + auth-mode-aware edit form
- Three-tier upstream subject resolution (JWT + userinfo + discovery)
- Per-credential Test Connection on /connections
- Per-user tool catalogs — different users see different tools based on upstream permissions
- MCP response cache scoped per (user, account_label) — no cross-user leakage
- Security hardening from review — SSRF, cache, rate limits, audit
Per-user upstream credentials#
The MCP gateway no longer pretends every user is the same upstream service account. Each developer authenticates as themselves — via OAuth or PAT — to GitHub, Linear, Slack, and any other MCP-enabled service. The upstream audit trail finally works end-to-end: tickets are assigned to real people, GitHub issues are created by the engineer who actually filed them. Per-key account overrides let one user maintain multiple identities (personal + work GitHub) and pick which one a given API key uses.
One-paste OAuth onboarding#
Paste an MCP server URL into the registration wizard. ThinkWatch handles Dynamic Client Registration with the upstream OAuth server, runs the auth probe, captures the OAuth client credentials at install time, and walks you through the consent screen. 401/403 from anonymous probes is correctly classified as
auth_required(with an amber status indicator on the catalog tile) rather than a hard failure, so partially-protected servers register cleanly.MCP Store#
A bilingual template registry is now built into the gateway. Templates ship with sensible defaults, the necessary OAuth scopes, and end-user-facing notes. The Linear OAuth template is seeded out of the box; more popular services follow. Display labels disambiguate multi-install templates so a personal GitHub install and a work GitHub install appear as distinct tiles instead of two indistinguishable entries.
Step-by-step registration wizard#
MCP server registration was reworked into a guided wizard with auth-mode-aware screens. The edit form refuses to save fields that don’t belong to the current auth mode, and tool-call / install errors are now surfaced in plain language with actionable next steps instead of raw JSON-RPC error codes.
Three-tier upstream subject resolution#
When an upstream MCP server uses OAuth, ThinkWatch resolves the upstream user identity through a three-tier strategy: parse the JWT if present, fall back to the OAuth userinfo endpoint, fall back again to issuer discovery. The resolved subject becomes the cache and audit key, so two users who share an MCP server stay strictly separated downstream.
Per-credential Test Connection#
The
/connectionspage surfaces a per-credential Test Connection button. Verify your OAuth/PAT actually works against the upstream server before committing to it; the result is structured (auth_ok / auth_required / unreachable / tool_call_ok) rather than a single green/red dot, so you know exactly which step is broken.Security hardening#
Following an internal security review, the MCP path now has SSRF protection on probe URLs, the response cache is scoped per
(user, account_label)so OAuth/PAT data never leaks across users, rate limits are applied at every gateway hop, audit records are emitted on tool-call boundaries, and admin foot-gun guards prevent the most common misconfigurations (verifying static tokens at paste time, blocking obviously-wrong credential combinations).
April 2026
-
ThinkWatch Enterprise v0.2.0
Teams, spending controls, and a programmable API
- Teams — isolate keys, budgets, and analytics per business unit
- Rate limits & budget caps with fail-closed enforcement and spend alerts
- RBAC v2 — custom roles, permission history, one-click import/export
- Tokens moved to HttpOnly cookies — XSS can no longer steal sessions
- Full management API with OpenAPI docs and API-key auth
- Real-time dashboard powered by WebSocket
Teams#
Users and API keys can now be organized into teams. Each team gets its own isolated view of the analytics dashboard, its own user list, and its own scope when assigning API keys. A structured scope picker makes member assignment straightforward.
Spending controls#
A new limits engine enforces rate limits and budget caps at every layer — per user, per API key, per provider, and per MCP server. Limits are configured through a panel embedded directly in each edit dialog. When a budget is exhausted the gateway fails closed — no silent overruns. Budget alert thresholds let you set a warning before the hard cap is hit. Streaming requests are metered correctly; weighted token costs are supported for models with asymmetric pricing.
RBAC v2#
The role system has been completely reworked. Built-in and custom roles share a unified table. Custom roles are fully editable in a CodeMirror editor, support cloning from an existing role as a starting point, and track a full permission history so you can see who changed what and when. Roles can be exported and imported as JSON for environment parity. Role assignments are now scope-aware — a role granted at the team level applies only within that team.
Security hardening#
Access and refresh tokens have been migrated from
localStorageto HttpOnly cookies — the JavaScript-visible session surface is now zero. The refresh endpoint binds each token to the originating client IP, so a stolen cookie cannot be replayed from a different network. The WebSocket dashboard connection now uses a one-shot ticket rather than a long-lived credential.Management API#
The gateway now exposes a programmable management API authenticated by API key. A full OpenAPI specification is served alongside the gateway so you can integrate key lifecycle and provisioning into your own tooling or CI pipelines without touching the console.
-
ThinkWatch Enterprise v0.1.0
Public preview
- Multi-format AI API gateway (OpenAI, Anthropic, Gemini, Bedrock)
- MCP gateway with namespace isolation and tool-level RBAC
- ClickHouse-powered audit logs with multi-channel forwarding
- First-run setup wizard and built-in configuration guide
ThinkWatch is now in public preview. This first tagged release ships the dual-port architecture (gateway
:3000, console:3001), virtual API key lifecycle management, sliding-window rate limiting, circuit breakers, and the unified log explorer.The Helm chart and distroless container images are available alongside Docker Compose for self-hosted deployments.