Skip to content
ThinkWatch
Start typing to search the docs.

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

  1. 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 \u00e9 or \/.
  2. 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 and client_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 is Service 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’s tools/net.rs) went to standard error, and for a whole answer the gateway’s log also has a Service panicked error. 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 \n in 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.

    Changed#

    • thinkwatch-core crates (tw-bedrock, tw-breaker, tw-dialect, tw-guard) v0.71.0 → v0.72.0. Only tw-guard changes.
  3. 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_json is removed (restore now 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::rebook and sse::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 \u00e9 or \/. Bodies with nothing to restore are passed on unchanged, byte for byte.
  4. 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.
  5. 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.redact takes two new optional keys, match (contains or regex) and case_sensitive. A rule without match is looked for as plain text, in which ., *, \d and the like have no special meaning; a rule without case_sensitive ignores 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 old security.pii_redactor_patterns setting. 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, with audit.body_redact_pii on, 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 through PATCH /api/admin/settings needs "match": "regex" and "case_sensitive": true on 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 match or case_sensitive logs Unreadable guard policy and 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, writes match on 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 remove match and case_sensitive from every custom rule in security.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, object and phase. Built-in rules still search those values, and every other value is searched by every rule as before, including fields such as user_id or aws_access_key_id inside 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 “Regex x, case-sensitive”. Trying a sample in the dialog uses the same settings: POST /api/admin/security/redact/test takes match and case_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 into fco_…<<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 LICENSE is 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.md explains how.
  6. 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 --restart switches a server back.
    • The request store’s schema is unchanged; nothing is cleared.
    • Configuration: a custom redaction rule takes two new optional keys, match (contains or regex) and case_sensitive. A custom redaction rule without match is 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 needs match: regex, and case_sensitive: true to keep telling cases apart. Existing configurations load unchanged; a configuration that uses match or case_sensitive on 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:
      • CustomRuleSave and SecurityTestRequest: match applies to redaction rules too (contains or regex; contains when not given); new case_sensitive (off when not given).
      • Matcher: contains and regex gain case_sensitive. A custom redaction rule is shown as contains or regex; a tool-call rule reports true unless its pattern starts with (?i).
      • RuleSnapshot gains case_sensitive; a custom redaction rule’s snapshot now carries matching.

    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 into fco_…<<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, object and phase. 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 as user_id or aws_access_key_id inside 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: contains looks for the text exactly as written, so ., * and the like have no special meaning, and regex takes a regular expression. A rule matches regardless of case unless it sets case_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.

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

    Added#

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

    Fixed#

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

    Changed#

    • The Public Gateway URL hint in Settings says where an empty protocol or host comes from (the address the console is open at) and that port 0 is the gateway’s listening port, and names Add to ThinkWatch Lite among what uses the address.
  8. 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.
  9. 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.

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

    Added#

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

      • "key" when the key has limits of its own on the AI gateway: limits lists only the key’s limits, each with used counted for the key, and usage is the key’s own (across its rotations).
      • "user" when it has none: limits lists its owner’s effective limits (roles, including those a team grants, merged most-restrictive, then the user’s overrides), each with used counted for everything the owner does, and usage is the owner’s total over all of their keys. With no limits on the owner either, limits is empty.
      {
        "scope": "key",
        "usage": {"requests_today": 12, "tokens_today": 48210,
                  "requests_month": 340, "tokens_month": 1290455,
                  "cost_usd_month": 3.82},
        "limits": [
          {"scope": "key", "kind": "tokens", "window": "daily", "window_secs": null,
           "limit": 100000, "used": 48210, "resets_at": "2026-10-12T00:00:00Z"},
          {"scope": "key", "kind": "requests", "window": "1m", "window_secs": 60,
           "limit": 60, "used": 4, "resets_at": null}
        ],
        "expires_at": "2026-12-31T00:00:00Z"
      }

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

    • An API key’s own limits are edited in the console. The key’s edit dialog has a Limits tab: rate limits (requests or weighted tokens over 1m, 5m, 1h, 5h, 1d or 1w) and budgets (daily, weekly or monthly weighted tokens), each with what it has used, added, changed and removed through the existing limits endpoints. They follow the key across rotations. Reading needs rate_limits:read and changing rate_limits:write, in a scope that covers the key, as before; without write access the tab is read-only, and without read access the dialog is unchanged.

    Fixed#

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

    Changed#

    • The legacy monthly token quota is removed. Every request counted its tokens into quota:{<user or key>:<model>}:used:<YYYY-MM> in Redis against a limit nothing could set, so it never refused anything. The gateway no longer reads or writes these keys; existing quota:* keys can be deleted. Monthly limits are budgets. Its metric, gateway_quota_overflow_total, is gone too: the Grafana overview panel that plotted it shows routes at their cap (gateway_route_capped_total) instead.
    • The security.rate_limit_fail_closed hint in Settings names everything the setting now covers.
    • thinkwatch-core crates (tw-bedrock, tw-breaker, tw-dialect, tw-guard) v0.69.0 → v0.70.0. The four crates do not change beyond their version.
  13. 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 --restart switches a server back.
    • The request store’s schema is unchanged; nothing is cleared.
    • Configuration: an upstream takes two new optional keys, balance: and signed_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:
      • ProviderView gains balance (the latest reading), balance_setting (what the configuration says) and signed_in; ProviderInput gains balance; ProviderTestResult gains balance.
      • New types Balance, BalanceQuota, BalanceWindow, BalanceScope, Spent, SpentPeriod, BalanceSetting.
      • New event balance_updated and endpoint POST /providers/{name}/balance/refresh: 404 control.balance_none for an upstream with no balance to read, 502 with 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) or thinkwatch (a ThinkWatch Enterprise gateway); off reads 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: zai or signed_in: bigmodel, and ProviderView.signed_in says 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.

  14. 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 content in the template they were trained on instead of answering with tool_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 of stop. 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), the thoughtSignature on 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, or reasoning as 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 a tw1.ch. signature, read back on the next turn, and written as reasoning_content only 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 functionCall part, 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 the functionCall part; a call with no real signature gets the skip_thought_signature_validator placeholder as before. A Gemini request forwarded as sent that carries a signature from another route on a functionCall part 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.
  15. 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.
  16. 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 --restart switches a server back.
    • The request store’s schema is unchanged; nothing is cleared.
    • Configuration: a routing rule’s when takes a new optional time condition (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:
      • ConditionField gains time.
      • TurnView gains context_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, and reasoning_content is 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 thoughtSignature on a functionCall part is read from upstream answers and from client history, carried through Anthropic and Responses clients, and written back on the functionCall part 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_calls field. 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 with Session::keep_text_calls, so a copy it records matches what its client received.

    Compressed bodies. A request body sent with Content-Encoding: gzip, deflate or zstd is 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 with gw.request.unsupported_encoding, and a body that fails to decode with gw.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 when can hold time, one or more windows in the core machine’s local time, written as [days ]HH:MM-HH:MM: days are mon…sun or ranges such as mon-fri and fri-mon, omitted for every day; the start is inclusive, the end exclusive, 24:00 allowed; 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}/context estimates, 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.

  17. 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": null in 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.
  18. 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 a 4xx or 5xx status_code instead of 200, so error rates that count status_code ≥ 400 include them; client_status keeps the 200 the 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 — Responses response.completed, response.incomplete or response.failed, Anthropic message_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, after response.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 200 can still report an error in the stream: an Anthropic error event (an overloaded_error arrives this way), Responses response.failed, an error in 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_outcome upstream_error, client_status 200 (the status the caller’s response went out with), and the status_code and error_type that answer would have had, read off the error’s own type or code. A rate limit or a spent quota is 429 (UpstreamRateLimited); a context too long or another invalid request 400, an unknown model 404, an overload 529 or 503 (ProviderHttpError); an error that names no status 502 (ProviderError). The route’s health and circuit breaker treat it as they treat that answer: a 5xx, a 429 or 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 — carry client_status too.
    • 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: throttlingException when it throttles partway through, validationException, internalServerException, serviceUnavailableException and others. Each was logged as the stream breaking off in transit (502, error_type transport) 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: throttling 429 (UpstreamRateLimited), an invalid request 400, a model timeout 504, the service unavailable 503, any other exception 500 (ProviderHttpError). Access denied is logged as a refused credential (UpstreamAuthError), without Bedrock’s words, which name the IAM principal, as for a 403 answer. 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 as transport 502.
    • A Responses WebSocket turn ends at its last event. On GET /v1/responses over 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 after response.completed held 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 attribution breakdown 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 null error in a stream chunk is not an error (tw-dialect). Some OpenAI-compatible relays and Gemini-format upstreams write "error": null in 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": null and no output went to an upstream of another format as a failed one. A null error 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 a null error as well.
      • tw_dialect::convert::ends_answer recognises 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 locate module, content::places, content::places_text and RulePlaces, content::Rule::draw, Ledger::placeholder_of, RuleSet::custom_pattern, tools::Rule::find_all, and the fields pattern, arguments, capped, places and path on tools::wall::Verdict. Secret redaction, the content filter and tool-call inspection find, replace, refuse and cut exactly what they did before. Verdict::arguments is 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.
  19. 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": null in 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 --restart switches 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:
      • HistoryRow loses bytes and gains sent_bytes, received_bytes and egress. RequestFinished carries sent_bytes and received_bytes instead of bytes; RequestFailed and RequestCancelled carry them as optional (absent when no attempt reached an upstream). RequestRouted and in-flight requests carry egress; each attempt in the routing view carries proxy. ReplayOriginal.bytes becomes received_bytes.
      • Summary, CostBucket and CostBucketGroup gain sent_bytes and received_bytes; CostBucket also gains its token counts and the first-token ttft_p50_ms, ttft_p95_ms and ttft_samples of its bucket. CostDim gains egress (the direct group is named "").
      • New endpoints GET /latency/client (first-token latency by key) and GET /request/{id}/live (live content, an event stream; 404 control.request_not_running once the request has ended).
      • RequestDetail gains heads, upstream_request_body and upstream_response_body. response_body is now what the client received; for a converted request the upstream’s own answer is in upstream_response_body.
      • SecurityEventView gains session, sent_model, direction, locations, more_locations, rule_snapshot and outcome_detail. The live SecretsFound items, ContentMatched and ToolCallFlagged carry the same detail as detail.

    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}/live streams 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.completed and 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 usage carries 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": null in a stream. Some OpenAI-compatible relays and Gemini-format upstreams write "error": null in 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": null was marked as failed. A null error 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.

  20. 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 tokens rate limits weigh a cache write by the model’s cache_write_weight and a read by its cache_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_tokens on 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 400 that names cache_control, cachePoint or 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.
    • 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 with 400, 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_tools input item and sends no top-level tools. 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 OpenAI by 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’s encrypted_content (tw1.c.…) base64-encoded, not encrypted; with audit.body_redact_pii on, 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 system or developer message 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) and text.verbosity (Responses) carry across the conversion to GPT-5 and later models. Other models still leave it out.
  21. 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_start or failover.stream_start_wait_secs makes 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.
  22. 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.

  23. 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 --restart switches 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_start and failover.stream_start_wait_secs are 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 new failover.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}/abort and POST /sessions/{id}/abort (→ Aborted { requests }).
      • Attempts gain the outcomes idle_timeout (with the time waited) and aborted; a request aborted by the user is recorded with the failure source aborted.
      • FailoverView gains idle_timeout_secs and loses next_on_slow_start and stream_start_wait_secs; the slow_start outcome is gone.
    • 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.

    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 tools and declares every tool in an additional_tools input 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.verbosity reaches 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.

  24. 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.
  25. ThinkWatch Core v0.66.0 GitHub ↗

    This release lets an upstream’s models be added by hand. An upstream that answers /v1/models but 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 --restart switches a server back.
    • The request store’s schema is unchanged: upgrading from 0.65.0 keeps the request history.
    • providers[].models is 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 wrote models, 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.
      • ModelRow gains manual (the model was added by hand) and listed (the upstream’s own list has it).
    • 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.

    Models added by hand. providers[].models used 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_only still limits what is used. Changing the list takes effect on reload, without asking the upstream for its list again.

  26. 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 a T; 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 --set flag of your own that names ghcr.io/thinkwatch/think-watch-server or …-web keeps overriding the chart’s new default, and a copy of deploy/docker-compose.yml keeps the old name: change them to ghcr.io/thinkwatchproject/…. A pinned image tag has no v: 3.2.1, not v3.2.1.

    Fixed#

    • Image names. The Helm chart (image.server.repository, image.web.repository) and deploy/docker-compose.yml named ghcr.io/thinkwatch/think-watch-server and …-web, which this project does not publish to and which refuses anonymous pulls, so helm install with the default values and docker compose pull failed. Releases are published to ghcr.io/thinkwatchproject/think-watch-server and …-web, and both files now name them. The tag examples in the chart’s README and values-production.yaml.example wrote v0.2.0; published tags have no v, and the examples now say 3.2.1.
    • SLO latency percentiles. GET /api/admin/slo reported 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 %M in ClickHouse’s formatDateTime is the month name. They now read 04: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 from written YYYY-MM-DDTHH:MM:SS, the form the API’s error message suggests, dropped every row of that day from items while total still counted them: the list compared the times as text. The console sends YYYY-MM-DD HH:MM:SS and 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 answer 500 and 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.0 in 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 as auth.totp_failed with "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 log logger 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, keep clickhouse.image at 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).
  27. 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 as 10-08, token counts as 1.5k.
  28. 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 --restart switches 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}/transcript takes TranscriptQuery (from_turn, optional: only the turns from that index on). A client written for protocol 40 that sends null is refused.
      • Transcript gains total_turns and settled_turns, the number of leading turns that will not change any more; asking from settled_turns next time returns only what is new.
      • DryRunCandidate gains share: 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_bytes of 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.json woke 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.

  29. 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.
  30. 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.

    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-Length is already over the limit is refused without reading the body. The limit is enforced on the bytes actually received, so a Content-Length that understates the body does not get around it. An oversized request is answered with 413 in the client’s own error format (request_too_large for Anthropic clients), and x-thinkwatch-error: request.

  31. 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.
  32. 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 --restart switches 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.*.reasoning and .image_input); every 0.63.0 configuration loads.
    • Changed in the protocol:
      • ModelRow gains reasoning, reasoning_source, image_input and image_input_source (SpecSource).
      • ModelSpecSave gains reasoning and image_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 by config.model_spec_nothing_set because the sentence now names four fields.

    What the model listing carries. GET /v1/models adds 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’s supports_vision.

    Reasoning and image input set by hand. providers[].model_specs takes reasoning and image_input (true or false) next to context_window and max_output_tokens, each overriding the price table on its own. PUT /provider-model-spec sets 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.

  33. 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 del removes them (the new keys start route_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 tokens rate limit never refused a request before. It now does once its window is full, so a deployment with token limits will see 429s 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 a rediss:// URL over plain TCP. A rediss:// URL pointing at a port without TLS now fails at start: point it at the TLS port, or write redis://.
      • The certificate must name the host in a subjectAltName. One that names it only in its CN, which redis-cli accepts, is refused.
      • Cluster nodes that announce IP addresses their certificates don’t name can’t be reached.
    • budget_unavailable in dashboards. With Redis down and security.rate_limit_fail_closed on, a request that has a budget is refused as budget_unavailable, not rate_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: point DATABASE_URL at Postgres itself or at a pooler in session mode (the Helm chart’s README has the details).

    Fixed#

    • Token limits refuse requests. A tokens rate 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 requests limits on a user (per minute and per hour, say), every request that passed was counted twice, and only one of the limits could refuse; with security.rate_limit_fail_closed on, every request was refused as rate_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-After says when to retry. A 429 from the gateway’s own limits said Retry-After: 30 whatever 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 sends x-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 a redis-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:// and rediss-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_CERT names a PEM file with that CA, which is then trusted alone; the Helm chart sets it from a Secret given in redis.caSecret. The chart’s README describes both.
    • Several instances starting at once. Server instances starting together against one database — a Helm replicaCount above 1, a rolling upgrade, an autoscaler adding pods — applied the schema side by side, and all but one could exit with Database 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, logging Another 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 in startupProbe (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_days above 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 on 5432, Redis on 6379 and ClickHouse on 8123, whatever their externalUrl said, 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 follow postgres.externalUrl, redis.externalUrl and clickhouse.externalUrl: every port a URL names, and the client’s default for its scheme where it names none. networkPolicy.extraEgress adds egress rules as written, for ports no URL names (Redis Cluster nodes announcing other ports, an upstream or MCP server on a port other than 443). The chart’s README describes both. Port 9000, ClickHouse’s native protocol, is no longer allowed: the server reaches ClickHouse over plain HTTP only (an http:// URL; HTTPS is not supported). An S3 endpoint on 9000 (RustFS, MinIO) configured outside the chart needs a rule in networkPolicy.extraEgress.
  34. 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.
  35. 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 --restart switches 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-test ordering 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_concurrent now 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: GroupView gains weights (every member’s weight for a load-balance group, empty otherwise) and balance_by (BalanceBy); GroupInput takes both (weights missing = all 1). The dry-run’s DryRunCandidate gains weight, ttfb_ms, success_rate and balance_factor; DryRunResult gains balance_by.
      • Upstreams: ProviderView and ProviderInput gain max_concurrent. ModelRow gains context_window_source, max_output_tokens and max_output_tokens_source (SpecSource: price_table or manual). New: PUT /provider-model-spec (ModelSpecSave → ConfigWritten).
      • Failover: FailoverView gains next_on_slow_start and slot_wait_secs.
      • Attempts: AttemptView gains queued_ms, skipped (ServeSkip::Busy) and usage (AttemptUsage); AttemptOutcome gains slow_start.
      • Keys: ClientView gains limits (KeyLimitView) and unpriced_models; KeyInput gains limits (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.
    • 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.

    Weights and distribution in load-balance groups. A member of a load-balance group 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_by adjusts the weights automatically: latency gives faster upstreams more, health gives upstreams that fail less more, latency-health both; 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-test and 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-test uses 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 after stream_start_wait_secs is 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 outcome slow_start and the input tokens the upstream may have billed. It is off by default; with it on, stream_start_wait_secs must be at least 5, and 30 or more suits models that think before they write.

    A concurrency limit per upstream. providers[].max_concurrent caps 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_specs gives 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-spec sets or clears one model’s entry.

    Usage limits per gateway key. clients[].limits lists 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: true counts cache reads too. Cost is in US dollars, at least 0.01; models without a price and upstreams with billing: free count 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_days at least 1, 7 or 31 days).
    • A day, week or month limit that is used up answers 429 with Retry-After until the reset and x-should-retry: false, so the official SDKs don’t retry it; OpenAI-format bodies carry insufficient_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::KeyLimitAlert fires at 80% and when a limit is reached.

    WebSocket turns. Each response.create on 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 with response.failed and 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.

  36. 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/v4 and 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.
  37. 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-data is 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 /v1 appended 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.
  38. 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 --restart switches 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 aliases table, and to in 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 allow is 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’s allow through one of these gaps is now refused.
    • Changed in the protocol:
      • New: GET /aliases (AliasesView), POST /aliases and PUT /aliases/{name} (AliasSave → ConfigWritten / AliasWritten), DELETE /aliases/{name}, POST /alias-preview (AliasPreviewRequest → AliasPreview) and GET /aliases/{name}/usage (AliasUsage).
      • RuleView.to and RuleInput.to are a RuleTarget: an upstream or group name as before, or a list of PinnedModel { provider, model }.
      • KnownModel gains alias (the alias’s models, when the entry is an alias) and aliases (the aliases that list a model); GET /models lists aliases. ModelRow gains aliases.
      • The dry-run result gains candidate_models, one DryRunCandidate { provider, sent_model, model_via } per candidate.
      • ServeSkip gains not_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-confirmed and PUT /plugins/order → PUT /plugin-order. At the old paths they shadowed an upstream, proxy or plugin of the same name: an upstream named test or preview, or a proxy named test, could not be edited or deleted. order, inspect, rewrite and confirmed are now valid plugin ids.
    • 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_id and control.plugin.reserved_id, since no plugin id is reserved any more.

    Model aliases. An alias is another name for the same model, written in a top-level aliases table 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/models lists 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 allow and a rule’s when.model written for an upstream model name also cover the aliases that list it; written for an alias, they cover only the alias.
    • set.model may 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.create on 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’s allow with the same inheritance, and gets the rules’ set.max_tokens, set.thinking and deny as evaluated for that request; one that can’t be sent is answered with response.failed and 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 against allow, 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’ allow entries and the rules’ when.model and set.model that name it. When the old name stays in the alias’s list, allow and when.model keep it: the name of a listed model already covers the alias.

    Pinned models. A routing rule’s to may list upstreams with the model to send each, tried in order. The model is sent as written: aliases and set.model do 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-model and x-openai-model headers. 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/models carries its context window where the price table knows it: context_window, context_length and max_input_tokens, plus supports_1m on the Anthropic shape and inputTokenLimit/outputTokenLimit on the Gemini shape. Claude family tiers now include fable and mythos.

    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). Under enforce it 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 protects config.yaml on 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 /v1 appended to it, which used to return 404. Requests, connection checks and model-list fetches all use it.

  39. 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.

  40. 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.yaml still set passthrough or route under client_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.
  41. 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 --restart switches 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_version and a list of ConfigFix) and POST /config/repair (RepairConfig: ConfigRepairRequest { base_version } → ConfigWritten). A web view may call both.
      • ConfigFix has kind (unknown_value or unknown_field), field (a path such as providers[0].protocol), line, value (masked) and now (the default the field goes back to, when it is a single value).
      • In safe mode, Status.config_rejected is set from the start when the configuration does not load, and Status.gateway_addr is null as before.
    • Message codes, compared with 0.60.0:
      • New: config.unknown_variant (field, value, expected), config.unknown_field (field, expected) and control.config_not_repairable.
      • A value outside its choices and an unknown field used to come as config.unparsable with serde’s sentence in detail; they now use the two new codes. Other parse errors still use config.unparsable.

    Safe mode with a configuration that does not load. twcore serve --safe used 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_rejected says 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 --safe exit, since nobody could connect to the control plane.

    One-click repair. GET /config/repair lists what a repair would change, and POST /config/repair makes 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.
  42. 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.yaml holds passthrough or route for it under client_probes:. Core does not start with either, and the app shows that core is not running until each one is changed to forward or 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.

  43. 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.intent takes 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 --restart switches a server back.
    • The request store’s schema is unchanged (25): upgrading from 0.59.0 keeps the request history.
    • client_probes takes intercept or forward. passthrough and route are gone, and a configuration that still sets either does not load. Change them to forward, or delete the line: health checks and warm-ups default to intercept, titles, topic detection and suggestions to forward. A configuration that never set client_probes is not affected.
    • Changed in the protocol:
      • ProbeMode is intercept | forward.
      • DryRunOutcome no longer has passthrough. A dry run of a forwarded helper request evaluates the rules and ends in route, deny, no_match or unavailable, like any other request.
      • RouteSave.route_probes is removed. Saving a route no longer changes client_probes.
    • Message codes, compared with 0.59.0: control.unknown_probe_class is removed, along with route_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. passthrough and route both sent such a request through the routing rules. They differed only in whether it carried its class, and so in whether a when.intent condition could match it. The names suggested that passthrough skipped routing, and the dry run said so too: it stopped before the rules and reported passthrough, while the gateway evaluated the rules for that request like for any other.

    • forward replaces both. The request carries its class, so when: { intent: titling } or when: { intent: assistant_internal } matches it without any other setting.
    • intercept is unchanged: the gateway answers the request itself, nothing is sent upstream, and no rule is evaluated. A when.intent condition never matches a class set to intercept.
    • 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, /storage reports recording: false, and the newer version opens the store unchanged later.

  44. 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: and output_limit:, with the lines indented under them, are deleted from the security: section of config.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.

  45. 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 models table change; the conversion keeps each deployment’s behaviour.

    Read before upgrading#

    • Back up system_settings and models first, 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, namespace thinkwatch here):

      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.enabled is on: the chart then leaves the replica count alone, and it stays at 0. With Docker Compose, docker compose stop server before 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_patterns and security.tool_inspection become security.content, security.redact and security.inspect_tools, and are deleted, in one transaction during the boot migration. The conversion is recorded in security.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, an output_guardrails value it could not read) is left out, each with a warning in the start-up log. A model’s output_guardrails length cap becomes max_output_tokens (below), and the column is dropped. To see what was converted, read the three keys from Settings or system_settings afterwards; 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 use ID_NUMBER and CARD_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-host rule 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-host records 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 to disable if that is not wanted.

    • “Warn” and “log” are one action now, “record only”, and hidden characters are content filter rules: unicode-tags and bidi-controls, plus zero-width and private-use, which ship off. security.hidden_text: block converts to those two rules refusing, warn and log to recording, off to switching them off. Recording writes an audit event: hidden_text: log used to reach only the application log, and now writes gateway.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 instructions and the like) write gateway.content_flagged events 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 both max_tokens and max_completion_tokens has 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 given max_completion_tokens on OpenAI’s own endpoint, whose reasoning models refuse max_tokens, and max_tokens elsewhere. 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’s output_guardrails field is gone: a request that still sets one (anything but null or []) is refused with 400, so a script cannot believe answers are still capped. max_output_tokens (1 to 2147483647, null for 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_error for OpenAI-style APIs). Keyword and regex rules used to refuse with 400. In gateway_logs, its error_type is PolicyBlocked, where it was TransformError.

    • 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:write and the guard’s own permission: pii_redactor:write for security.redact, content_filter:write for security.content and security.inspect_tools, through PATCH /api/admin/settings. 2.2 checked settings:write on the server and the guard permission in the console; both are checked on the server now. Trying a sample takes pii_redactor:read or content_filter:read. Reading the policies — GET /api/admin/security, which the console’s security page loads — takes settings:read. The seeded admin and super_admin roles hold all of them.

    • Console API changes. GET /api/admin/security lists each guard’s mode and every rule, and POST /api/admin/security/{guard}/test tries a sample; they replace /api/admin/settings/content-filter/test, /content-filter/presets, /pii-redactor/test, /tool-inspection/rules and /tool-inspection/test, which are gone.

    • Audit events. Every rule that matches writes one event per request: gateway.content_flagged, gateway.content_stripped and gateway.content_blocked; gateway.redaction_flagged and gateway.redaction_replaced; gateway.tool_call_flagged and gateway.tool_call_blocked as before. gateway.hidden_text_flagged and gateway.hidden_text_blocked are 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). With audit.body_redact_pii on, captured bodies are redacted with the outbound redaction rules, built-in ones included, whatever the redaction mode.

    • Metrics renamed. gateway_hidden_text_total is gone: hidden characters count in gateway_content_matched_total{outcome,custom} with every content rule. pii_pattern_invalid_total{pattern} is now guard_policy_invalid_total{guard} (a stored policy with a rule that does not compile; the rule is left out), next to guard_policy_unreadable_total{guard} (a stored policy that cannot be read; the guard runs on its factory policy). Redaction counts values in gateway_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-breaker and tw-bedrock move 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.
  46. 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, and config.yaml keeps 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.exe no 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 --restart switches 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_text or security.output_limit does 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 as export { manifest }, does not load.
      • It may hold objects, arrays, strings, numbers, true, false and null. 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_at gives the line and column; gw.plugin.manifest_not_data is used when there is no position.
    • The manifest holds the plugin’s configuration.
      • on_error ("reject", the default, or "skip") is a manifest field.
      • match is the scope the plugin runs with. It used to be a suggestion that filled in the configured scope at install.
      • Settings take value in place of default: it is the value the plugin gets. A setting still written with default does not load; rename the field to value. A setting without value gets its type’s empty value ("", 0 or false).
    • Plugin entries in config.yaml hold only id, file, sha256 and enabled.
      • Entries written by 0.58.0 may still have on_error, scope and settings. These are ignored whatever they contain, so the configuration still loads, and they are removed the next time core writes the plugins section.
      • Their values are not carried over. To keep them, write them into the plugin’s manifest, or set them again in the app.
      • rewrite and confirmed are now reserved: a configuration with a plugin of either id does not load.
    • 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_error defaults to reject, and a 0.58.0 manifest cannot set it.
    • Changed in the protocol:
      • Removed: PUT /plugins/{id}/source (ReplacePluginSource, PluginSourceReplace), UpdatePlugin, UpdatePluginConfirmed, PluginUpdate, and PluginView.settings (the values are in settings_schema[].value).
      • PUT /plugins/{id} is SavePlugin and PUT /plugins/{id}/confirmed is SavePluginConfirmed. Both take PluginSave: source, enabled and base_version.
      • New: POST /plugins/rewrite (PluginRewrite: PluginRewriteRequest → PluginSource), POST /plugins/confirmed (CreatePluginConfirmed) and POST /plugins/{id}/approve/confirmed (ApprovePluginFileConfirmed).
      • PluginCreate has only source, id, enabled and base_version. SettingSpecView.default is now value, and ManifestView has on_error. PluginView.on_error and PluginView.scope are what the plugin’s file says.
      • A web view may call CreatePlugin, SavePlugin and ApprovePluginFile. A client calls the three …Confirmed endpoints from native code after the user confirms, never from its web view.
    • Confirmations. A plugin that can rewrite tool calls (the reply.tool_calls permission) 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 /config and 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.
    • Message codes, compared with 0.58.0:
      • New: gw.plugin.manifest_not_data and gw.plugin.manifest_not_data_at.
      • Removed: config.plugin.blank_pattern and config.plugin.setting_type. The configuration fields they checked are gone.
      • control.plugin.needs_confirmation has a new sentence that names the four actions. Its argument (plugin) is unchanged.

    Plugin files hold their configuration. The app changes on_error, match or a setting by rewriting the plugin’s source and saving it. POST /plugins/rewrite takes a source and the new values and returns the rewritten source; it reads and writes no file. POST /plugins/inspect reads 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_error and scope are the values to end up with, and settings changes 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_error and match of 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, match and 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 enabled changes, and the files are not touched.

    When a save writes the file, the plugin file, its approved copy and its sha256 in config.yaml change 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-language and wsl-paths write on_error and their settings’ value in 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 or on_error were 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.exe no longer needs the Visual C++ Redistributable; the C runtime is linked statically. The twcore.exe of 0.58.0 imported VCRUNTIME140.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.

  47. 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 --restart switches 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_text and security.output_limit are removed. A configuration that still has either key does not load: the app starts in safe mode, and twcore serve exits with the error.
      • Only configurations where one of these settings was changed are affected. A new configuration has no security section, 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: and output_limit:, with the lines indented under them, from the security: section of config.yaml.
      • Hidden characters are now checked by content-filter rules that follow the content filter’s mode (see Three guards).
    • Changed in the protocol:
      • Removed:
        • PUT /security/{guard}/limit (SetSecurityLimit, LimitSave);
        • OutputLimitDetail, HiddenItem, HiddenKind;
        • the events hidden_text_found and output_limited;
        • hidden_text and output_limit in Guard, SecurityDetail, SecurityView and SecurityCounts.
      • GET /sessions/{id}/transcript (SessionTranscript → Transcript, with TranscriptTurn, 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. RequestDetail has plugins and request_after_plugins, HistoryRow has plugin_changed, and ConfigOrigin has defaults.
      • TurnView has status.
      • The security types are now defined in tw-guard and keep their names. Changes:
        • SecurityRuleView and CustomRuleSave have label.
        • Matcher has email, cn-mobile-phone and builtin; RuleAction has strip; ContentMatch has codepoints.
        • SecurityTestRequest has label and action; SecurityTestResult has output and refused.
        • SecurityOutcome and SecurityOutcomeCounts have stripped; SecurityEventView has match and revealed.
        • The security counts in /summary are secrets, secrets_replaced, tool_calls, tool_calls_cut, content, content_blocked and content_stripped.
      • content_matched has match, action, outcome (ContentOutcome: recorded, stripped, blocked), count and revealed, and no longer blocked.
    • Message codes, compared with 0.57.1:
      • New:
        • 52 for plugins (config.plugin.*, control.plugin.*, gw.plugin.*). gw.plugin.reason only 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.
      • Renamed: gw.toolcall.cut → gw.toolcall.response_cut, gw.toolcall.blocked → gw.toolcall.response_withheld, gw.ws.toolcall_cut → gw.toolcall.connection_cut (its detail argument is now why). 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_guard and security.content_action_unknown take the place of security.guard_unknown and security.unknown_content_action, with sentences that list the new guards and actions.
    • Building from source needs an LLVM clang that can compile C to WebAssembly, and its llvm-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_flagged event, 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_bytes now 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 to plugins/ beside it. That is one configuration version, recorded in the history as defaults.

    Session transcripts. GET /sessions/{id}/transcript reads 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 restart and 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 system messages.
    • 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, off included.
      • 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.
    • 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 source upstream. A request failed if and only if its error is 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 gives gw.upstream.status.
    • TurnView.status carries 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-tags and bidi-controls are on; zero-width and private-use are off.
    • Actions. Content rules act with block, strip (new) or record when the filter is in enforce. 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: PROJECT gives <<TW_PROJECT_1>>.
    • New redaction rules. email and cn-mobile-phone (Chinese mainland mobile numbers) are built in, off by default.
    • What is screened.
      • Compaction requests are screened, and so are response.create frames 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.
    • 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 in enforce). 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.text and reply.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 under on_error: reject, or a refusal by the content filter refuses the whole request without failing over.
      • A plugin’s params.model renames 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 of config.yaml lists plugins in the order they run.

      • A plugin runs only while the SHA-256 of plugins/<id>.js matches its approved sha256. A changed file stops it within seconds, until it is approved again.
      • Each plugin has on_error (reject or skip), a scope (clients, models sent, upstreams) and settings.
    • 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.

      UpdatePlugin refuses the second kind with 403 control.plugin.needs_confirmation. A client calls CreatePlugin, ReplacePluginSource, ApprovePluginFile and UpdatePluginConfirmed from 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-language asks the model to answer in a chosen language.
      • wsl-paths converts 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.
  48. 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.
    • 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/skills folder appear under a shared folder, and Cursor’s hooks are scanned too.
    • Codex stopped reading OPENAI_BASE_URL in 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.

  49. 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, /healthz and WebSocket upgrades are unchanged.

  50. 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 --restart switches 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-id and bank-card (see below). With outbound redaction in enforce, such numbers in a request are replaced before it leaves; in observe they are recorded. security.redact.disable turns either off.
    • Routing estimates input size the way token counting does. Rules on input_tokens now 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, with HistoryCursor, ContentHit, ContentSide, SearchStop).
      • GET /upstreams/health (UpstreamHealth, UpstreamCheckup, ModelConsistency, ModelPair, InputVsEstimate, InputForModel, RatioView, CacheReads, CacheForModel, CacheTally, MedianView).
      • Event::RequestStarted has input_estimate; RequestFinished, RequestFailed and RequestCancelled have answered_model (both left out when absent).
      • Matcher has cn-resident-id (born_since) and bank-card (networks: CardNetwork, CardPrefix); SecretKind has personal.
    • 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/search applies 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/health reports, 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.5 against 4-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.

September 2026

  1. ThinkWatch Enterprise v2.2.0 GitHub ↗

    This release fixes authorization. The gateways never checked ai_gateway:use or mcp_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 seeded team_manager role 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 403 unless a role held by the key’s owner grants ai_gateway:use, and a request to the MCP gateway unless one grants mcp_gateway:use. The body names the missing permission, in the error format of the caller’s API. The built-in developer, admin, super_admin and team_manager roles grant both; viewer grants 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 Deny statements):

      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 to developer. 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:use only. A role without those actions (such as viewer) 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-scoped developer or team_manager needs 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_models is [], or whose list shares no model with what its owner’s roles grant, used to call any model; it now calls none, as allowed_mcp_tools: [] already did on the MCP gateway. The console never saves [] (clearing the picker sends null), 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 to null to leave it bounded by its owner’s roles only. Model entries still match by prefix, and a key narrowed to gpt-4o-mini under a role granting gpt-4o keeps gpt-4o-mini.

    • Run db/release_migrations/2026-09-30_retire_unchecked_permissions.sql once, after deploying. The server does not apply it. It adds teams:read to the team_manager role 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_manager keeps the old team:read it cannot use, so a team manager who is not a member of the team they manage still gets 403 opening 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:write now takes effect. It used to require global scope for every subject, so the seeded team_manager granted 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 holding team_manager (or a custom role with rate_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 explicit Deny on ai_gateway:use or mcp_gateway:use now 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-mini under a role granting gpt-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_manager role works at team scope. It granted team:read, but the team handlers check teams:read, so a team manager got 403 listing teams and opening the team, its roster or its roles. It now grants teams:read, and opening a team accepts teams:read scoped 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_team and audit_logs:read_all. Every log endpoint, audit logs included, is gated on logs:read_all at 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. DELETE on /api/admin/limits/{kind}/{id}/rules/{rule_id} and /api/admin/limits/{kind}/{id}/budgets/{cap_id} now answers 404 when 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_models or allowed_mcp_tools entry is refused with 400 when 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.
  2. 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 only providers:create. A custom role that has providers:create without providers:update can no longer test existing providers; the built-in admin role has both. A test with values typed into the request still takes providers:create. See Security below.
    • Refusing a route the provider does not serve answers model_not_served. POST /api/admin/models/{model_id}/routes still answers 400 when the import probe found no API the provider serves the model on, but error.type is now model_not_served, where it was bad_request. Scripts that matched on bad_request for this case need the new value. The request takes a new optional "force": true to create the route anyway.
    • error_type in gateway_logs for failed requests is always the error’s tag. An upstream HTTP error used to log its debug text, such as ProviderHttpError { status: 502, message: "…" }, and an upstream rate limit a cut-off UpstreamRateLimited { retry_after_secs: Some. They now log ProviderHttpError and UpstreamRateLimited, 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_logs treat 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_control breakpoints, so Claude on Bedrock re-read the whole prompt at full price every turn. Breakpoints now go out as Converse cachePoint blocks, 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, at cache_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 (temperature 1, no top_k, top_p at 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: Bearer header, and replaced in the provider’s Edit dialog. A Bedrock provider that sends an Authorization header 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 needs bedrock:ListFoundationModels and bedrock:ListInferenceProfiles; the AmazonBedrockLimitedAccess policy 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.created audit row records the refusal it overrode in a new refusal_overridden key.

    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; disabled wins over reasoning_effort.
    • Core crates at ThinkWatch-Core v0.55.0. tw-dialect, tw-guard and tw-breaker move from v0.43.0, and tw-bedrock joins 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-breaker is 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’s base_url is now checked as an AWS region such as us-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/v1 sent 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 Bearer with 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, and providers:create was 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 takes providers:update, which lets a user point that provider elsewhere anyway; a test without one still takes providers:create. A user with providers:update alone can now use Test Connection in the Edit dialog, which used to answer 403.
  3. 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.

  4. 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: false under 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_error or a response.failed when 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?… or https://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.

  5. 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 --restart switches a server back to 0.55.2.
    • The request store’s schema is unchanged (22): the request history is kept.
    • groups[].session_affinity is removed. A configuration that still sets it (only session_affinity: false was 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.affinity and RoutingView.affinity (AffinityView, Stay): whether the request kept its turn’s route and why it stayed with the upstream that answered before.
      • Overview.failover (FailoverView).
      • AttemptOutcome has estimated.
    • New in the configuration: the failover section (see Setting a failing upstream aside).
    • Message codes: new config.failover_range and gw.upstream.stream_opening_error; removed gw.count_tokens.bedrock_model.

    A conversation stays where its cache is. Routing rules used to be evaluated for every request, and only load-balance groups kept a session on one upstream. A rule on input_tokens or 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_id and 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-balance takes turns between new conversations only. session_affinity and 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’s response.failed when the usage limit is reached, Bedrock’s throttlingException, 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 after failover.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 for quota_pause_secs (3600) when it names none. A rate limit with Retry-After (or Gemini’s retryDelay) sets it aside for that long, at most rate_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, for pause_secs (60), doubling with each further pause up to max_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_tokens and Gemini’s :countTokens routed 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 carries x-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 501 not_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 invalid signature in a thinking block). 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.

  6. 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-… and global.…, 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_BEDROCK turned 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 env block of settings.json, such as the Bedrock API key and access keys that /setup-bedrock writes 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_error or Responses’ response.failed, now leaves the request recorded as failed instead of finished, with the usage reported before the error.

  7. 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 every AccessDeniedException on 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.

  8. 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_unsendable and gw.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_search tool 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.

  9. 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 --restart switches a server back to 0.54.0.
    • The request store’s schema is unchanged (22): the request history is kept.
    • Changed in the protocol:
      • Protocol has bedrock.
      • ProviderInput.aws and ProviderView.aws (AwsKeys): the access keys, or the name of an AWS profile, and the region to sign for.
      • ProviderView.region and ProviderPreview.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 bedrock and providers[].aws. The 200K long-context threshold of a price sheet’s input_above_200k and output_above_200k now 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_mismatch
      • config.aws_profile.: no_home, unreadable, not_found, unsupported, no_keys
      • gw.upstream.: aws_token_expired, aws_profile_expired, bedrock_refused, bedrock_refused_unnamed, sign_failed, eventstream_broken, stream_exception, stream_error
      • gw.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.com is recognized as bedrock. It authenticates in one of three ways:

    • A Bedrock API key in key, sent as Authorization: Bearer.
    • AWS access keys in aws: access_key_id, secret_access_key and, 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 files AWS_SHARED_CREDENTIALS_FILE and AWS_CONFIG_FILE name) 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 a credential_process or 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-beta values 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 in base_url and the region in aws.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_tokens for a model that only Bedrock upstreams serve is answered with 501 not_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 on bedrock-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 become cachePoint blocks (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 in additionalModelRequestFields, 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, an error in 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/models and GET /v1/models/:model no longer stamp each model with the time of the request. created and created_at are now the Unix epoch, since the gateway does not know when a model was released.

  10. 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 upgrade on 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.

  11. 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.

  12. 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 /live reports.

    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 --restart switches 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_ms and HistoryRow.tokens_per_sec, and the new endpoints GET /token-rate and GET /token-rate/provider, which return TokenRateView { model, p50, samples }. GET /latency and GET /latency/provider now 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_start and response.created do not count. A response that is not streamed arrives whole and has no first token. GET /latency and GET /latency/provider now give percentiles of this time, so responses that are not streamed are left out of them. url-test groups 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-rate and GET /token-rate/provider give the median speed by model and by upstream, with the number of samples.

    The live rate. GET /live used 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.

  13. 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-Key and X-Client-Request-Id for 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.
  14. 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-Agent instead 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 field forward_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’s Cookie and Origin all went through. Outbound headers now come from three sources only:

    • Set by ThinkWatch: its own User-Agent, and content-type when the body was translated.
    • The upstream’s configuration: the credential and the headers written there. OpenAI-Organization and OpenAI-Project belong here, because they belong to the upstream’s key.
    • The client’s request, only for the headers the upstream’s protocol uses:
      • Anthropic: anthropic-*, except anthropic-dangerous-direct-browser-access.
      • OpenAI: Idempotency-Key and X-Client-Request-Id.
      • Gemini: none.
      • Every protocol: content-type and accept when the body passes through unchanged.

    An Anthropic request without anthropic-version now gets the default one. When the body passes through unchanged, identity fields that clients fill in themselves are removed from it: Claude Code’s metadata.user_id, and the Codex installation ID and turn metadata in client_metadata.

    forward_client_identity. With this field on, an upstream also receives the client’s own User-Agent, x-app, originator, version, session_id and x-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_version that the caller reports. ThinkWatch reported 0.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 reports 99999.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-lite and x-codex-beta-features.
      • The attestation, installation ID and turn metadata cannot be set in an upstream’s configured headers.
    • 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.
  15. 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 env in settings.json, the provider section of a new opencode configuration, and DeepSeek Harness’s refs. Restoring DeepSeek Harness no longer fails with “refs is not a scalar”, and its credentials file keeps its version when 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.yaml with 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, apiKeyHelper and similar), and Codex’s notify. 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.md files 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.yaml with 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_tokens of 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.json appears at once. The Traffic page does less work per event, and the macOS menu bar updates its menu in place while it is open.

  16. 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_DIR or Codex moved with CODEX_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.

  17. 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_rewritten and gw.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 instructions and input, and systemInstruction and contents, and uses the request’s prompt_cache_key when it has one. Codex sends one per conversation, and Codex conversations started in the same repository open with identical text.

  18. 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 --restart switches 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 new OAuthView.refresh and OAuthView.client_secret, and ProxyView.auth (which replaces has_auth) carry the settings as written; base_url_masked, SecretView and HeaderView.masked are gone. ProviderInput.base_url is required, ProviderInput.key is an optional string and every HeaderInput has a value; ProxyInput.auth is optional. SecretChange and ProxyAuthInput are gone, and so is the message code control.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/v1 was 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 in config.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 /config already 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.

  19. 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 covers opencode.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/files is 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.

  20. 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 --restart switches 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 monthly is gone, and a QuotaSeen with empty windows means 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 no monthly window, and a used-up count no longer marks the upstream used up or emits QuotaExhausted. credits are 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 weekly window 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 QuotaSeen with empty windows, so the app removes the quota at once instead of on its next read of /quota.

  21. 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 --restart switches a server back to 0.49.1.
    • New in the protocol: QuotaWindow.credits (a new type, QuotaCredits { total, used, remaining }), the quota window monthly, and session_log_bytes on RequestStarted and HistoryRow. New message code: gw.files.unsupported.
    • /v1/files and /files now 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/models answers Anthropic clients in the Anthropic shape: type, display_name, created_at, has_more, first_id and last_id, and, for models whose id reads as Claude, anthropic_family_tier, which Desktop uses to resolve aliases such as sonnet. The OpenAI fields stay on the objects, because clients that send only x-api-key are 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: system entries in messages, tool_addition and tool_removal blocks with defer_loading tools, thinking enabled without a budget, and x-deepseek-harness-* headers. A request is recognised by its User-Agent or a dsh_* field. To DeepSeek’s official endpoint it goes through unchanged. To any other upstream, every request from it, including count_tokens, has the dsh_* fields removed, its system entries appended to system in order, the tool change blocks and defer_loading removed and listed on the hop as a conversion lists what it drops, and a thinking without a budget rewritten to what the model accepts. The x-deepseek-harness-* headers are not forwarded, and the tool changes beta is taken out of anthropic-beta, with every line of a header sent on several lines kept. session_log_bytes gives the size of the session log a request carried, so the app can show that it carried one. /v1/files and /files answer 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_url host is api.z.ai or open.bigmodel.cn is read as a GLM Coding Plan upstream. Its responses carry no quota headers, so GET /quota asks 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 are 5h, weekly and monthly (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 report credits with the three numbers the endpoint gives. A 429 carrying a used-up quota code marks the window used up and emits QuotaExhausted. 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-sdk and carries Google’s internal Go toolchain (gl-go/ followed by cl/) is now reported as the client antigravity-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.bind names 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.

  22. 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 that twcore upgrade --version also installs an older release, and says that recent versions of the app (2026.9.17 and later) show sudo twcore upgrade --version <version> --restart with the version they need when the versions differ. It also says that twcore upgrade leaves 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.

  23. 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.

  24. 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 --restart switches a server back to 0.47.0.
    • GET /summary/routes returns an object, RouteStats { covered_since_ms, routes }, instead of an array of RouteHits; routes holds what the array held.
    • ChatgptLoginStatus.plan is removed. ChatgptLoginStatus.account carries 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/routes counts 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_ms is 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 is null when 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 in account, with the email and the plan. It is the same block as ProviderView.oauth.account and 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 twcore as 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-guard and tw-breaker).

  25. 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 --restart switches 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-flight returns an object, InFlight { now_ms, requests: [{ id, events }] }, instead of an array of start events. RequestStarted.session_fp is replaced by session. ChatgptUsage.email and ChatgptUsage.plan are removed; the signed-in account is in ProviderView.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 RequestStarted carries that session id, the one the request is stored under. The first routing phase now finishes before the start event, so RequestStarted also carries the route, the rule, the strategy group and the rules that rewrote the request. RequestRouted carries the final record, including rewrites from the second phase and denied_by for 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-flight returns 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 in GET /live add elapsed_ms, session, route, rule, group and upstream.

    Rule hit counts. GET /summary/routes reports, 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/by reports unpriced_requests and no_usage_requests, so a group with no cost can be told apart as unpriced, missing usage or free. GET /security/events adds total and by_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.account gives 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 --help begins 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.

  26. ThinkWatch Lite v2026.9.16 GitHub ↗

    Upgrade note: the proxy configuration now rejects misspelled field names, such as typ written for type. 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.

  27. 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 upgrade for running twcore as a service on Linux. ThinkWatch Lite 2026.9.16 includes this version.

    Upgrade notes

    • A misspelled field under proxies (for example typ: for type:) is now a configuration error; it used to be ignored. A configuration that contains one does not pass twcore 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 call sends 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 scan and twcore clients subcommands and the related control-plane endpoints and events are removed; POST /clients/{id}/key remains.

    Control key. listen.control.key in config.yaml holds the key, and twcore serve adds one to a configuration that has none. Every control transport (the unix socket, the loopback port on Windows and the remote port) starts with a Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s handshake keyed by it, and a wrong key is refused before any HTTP. twcore control-key prints the key; --rotate replaces it and closes the connections made with the previous one. The key is masked in GET /config, in the configuration history and in diagnostic bundles, and it cannot be changed through the control plane.

    Remote control port. listen.control.remote opens a TCP port for ThinkWatch Lite on another machine, in addition to the local channel. twcore init writes the section disabled, with a random port from 20000–32000. twcore remote enable [--bind B] [--port N] [--allow CIDR], twcore remote disable and twcore remote show change and report it, and a running core applies a change within a second. A source outside allow_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 change listen.control.

    Server deployment. scripts/install.sh installs twcore on Linux (x86_64, aarch64) as a systemd service, with a thinkwatch system user, data in /var/lib/thinkwatch and environment variables in /etc/thinkwatch/env; it checks the SHA-256 and never starts or restarts the service. Releases now include twcore-<target>.tar.gz for both Linux targets, holding the binary, twcore.service and LICENSE. twcore upgrade [--check] [--restart] [--version X.Y.Z] replaces a separately installed twcore with 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 of config.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.bind between all and 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.

  28. 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 写法;窗口权限按需收紧。

    提醒改为按当前状态核对:应用启动或重新连接网关时,已存在的凭据失效、配置未通过校验等问题会补发提醒,已解决的会自动收起,同一问题不会重复提醒。系统通知和界面中的错误原因均显示为中文。

    其他:重放请求与正常转发使用同一出站设置;菜单栏的进行中请求和生成速率改由网关统计;网关已在运行时重新打开窗口不再播放启动动画;上游表单的请求头列表第一行显示密钥所用的认证头。

  29. 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_required before 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 403 totp_enrollment_required, except /api/auth/me, logout, register-key and the TOTP status/setup/verify-setup calls. Setting up TOTP releases the session straight away. API keys are not affected: gateway, MCP and console tw- key traffic keeps working. While the setting is on, POST /api/auth/totp/disable is 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-After are unchanged. Code that reads the error type needs updating:

      • Chat Completions and Responses keep the {"error": {"message", "type", …}} shape, but type is 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) and server_error (5xx). The old tags are gone: rate_limited, policy_blocked, provider_http_error, provider_error, provider_timeout, transform_error, network_error and auth_error. A policy block is now permission_error with 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.failed event, where before it got a Chat-style error frame that SDKs skip, so the stream just stopped. An Anthropic client gets an error event whose type follows the status.
      • The error_type field in gateway_logs and the metric labels are unchanged.
    • 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. models gains three weights, cache_read_weight, cache_write_weight and cache_write_1h_weight. When a weight is unset, it is input_weight times 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_tokens in the log is still the whole input. The log detail gains cache_read_tokens, cache_write_tokens and cache_write_1h.
    • Output length limits now apply to streams. In 1.x, max_length output 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, persona and chinese; they used to be basic, strict and chinese. 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: true in their detail and count in gateway_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_logs row 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:

    • models gains three nullable columns: cache_read_weight, cache_write_weight and cache_write_1h_weight (ALTER TABLE … ADD COLUMN IF NOT EXISTS, CHECK (>= 0)).
    • system_settings gets an auth.default_role row, 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}:generateContent and :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. :countTokens and :embedContent are refused with 400.

    • The Responses API over a WebSocket. Connect to GET /v1/responses with Upgrade: websocket.

      • Each response.create frame is handled like a streamed POST /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 with store: false, which is how Codex works, and against any upstream format.
      • A new counter, gateway_responses_ws_connections_total, counts connections.
    • More places to put an API key. Gateway keys are also accepted in x-api-key (Anthropic SDKs), x-goog-api-key and ?key= (Gemini SDKs), as well as Authorization: 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 found gains revealed, the ASCII that the hidden tag characters spell.

    • auth.default_role can 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.com are now recognised, and the check reads the parsed host. A relay URL such as https://relay/api.openai.com no 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-guard and tw-breaker at 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.
  30. ThinkWatch Core v0.46.0 GitHub ↗
  31. ThinkWatch Core v0.45.0 GitHub ↗
  32. ThinkWatch Core v0.44.0 GitHub ↗
  33. ThinkWatch Core v0.43.0 GitHub ↗
  34. ThinkWatch Core v0.42.0 GitHub ↗
  35. 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, metadata and cache_control now 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_tokens leaves 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-beta and anthropic-version headers. 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 observe mode.
      • The hidden-character check starts in warn mode.
      • 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 enforce or block.
    • 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) and enforce.
      • 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_flagged or gateway.tool_call_blocked) and counts in gateway_tool_call_flagged_total.
      • Admin API: GET /api/admin/settings/tool-inspection/rules and POST /api/admin/settings/tool-inspection/test.
      • Console: a card on the security page, plus a sandbox tab.
    • Hidden-character check (security.hidden_text: off, log, warn or block).
      • 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: warn writes gateway.hidden_text_flagged; block refuses with 403 and writes gateway.hidden_text_blocked.
      • Console: a card on the security page.
    • Tool-call arguments get their PII back. A model asked to e-mail a@example.com used to call the tool with {{EMAIL_1}} as the address.

    Changed#

    • One pipeline for /v1/chat/completions, /v1/messages and /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_control breakpoint. 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, seed and 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_EMAIL was reported as CUSTOM.
    • :latest could point at a main build rather than the release, which is what happened for v1.0.2’s server image. Only the release workflow sets :latest now.
    • 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.
  36. ThinkWatch Core v0.41.0 GitHub ↗
  37. ThinkWatch Lite v2026.9.14 中文 GitHub ↗

    上游的 API 密钥和请求头中的 ${变量名} 现在可以读取用户配置的环境变量。macOS 上读取登录 shell 中的变量,~/.zshrc 等文件中 export 的变量都会生效,此前从程序坞或访达打开时读不到;Windows 上每次启动网关都重新读取系统设置中的环境变量。代理相关的变量和 PATH 不会被读取。修改变量后重新打开应用即可生效。

    上游表单中,API 密钥与请求头的说明改为写明读取系统环境变量,请求头可一键插入网关密钥名称和 Access Token。

    熔断改用与企业版共用的状态机,行为不变:连续失败三次后熔断 60 秒,再放行一次探测;所有上游都不可用时照常放行,只有一个上游时不熔断。

  38. ThinkWatch Lite v2026.9.13 中文 GitHub ↗

    自动检查发现新版本时改为发送系统通知,点按通知后再打开更新窗口,不再自行弹出。更新窗口只显示版本号,不再显示更新说明。

  39. ThinkWatch Core v0.40.0 GitHub ↗
  40. ThinkWatch Lite v2026.9.12 中文 GitHub ↗

    Windows:网关监听选择「局域网」时,可以选用系统中的网卡(如「以太网」),此前保存会失败。快捷键改用 Ctrl(Ctrl+F 搜索、Ctrl+B 收起源列表),字号与字体按 Windows 调整,「访达」「菜单栏」等说法改为 Windows 对应的名称。安装版只运行安装目录中的网关,「关于」中显示的路径也已修正。

    开机启动的说明改为一句话,直接写明开启后的效果。

  41. ThinkWatch Core v0.39.0 GitHub ↗
  42. ThinkWatch Core v0.38.0 GitHub ↗
  43. ThinkWatch Core v0.37.0 GitHub ↗
  44. ThinkWatch Lite v2026.9.11 中文 GitHub ↗

    修复 2026.9.10 中所有修改操作都会失败的问题:保存设置、新建或编辑上游、接管客户端等操作会提示控制面需要凭据,本版恢复正常。

    Windows:启动时不再弹出网关的命令行窗口;客户端页中的配置文件路径改为 Windows 的写法,Zed 的配置位置以及 Continue、Gemini CLI 的手动配置步骤按 Windows 给出。

    报错信息改为按界面语言显示,系统或外部服务返回的原文除外。

  45. ThinkWatch Core v0.36.0 GitHub ↗
  46. ThinkWatch Core v0.35.0 GitHub ↗
  47. ThinkWatch Core v0.34.0 GitHub ↗
  48. ThinkWatch Lite v2026.9.10 中文 GitHub ↗

    首次提供 Windows 版,分 x64 与 arm64 两个安装程序,在发布页下载。Windows 版未做代码签名,首次运行时 SmartScreen 会拦截,选择「更多信息」→「仍要运行」即可。

    新增 Z.ai / BigModel 账号登录:在上游的服务类型中选择「Z.ai / BigModel 账号(登录)」,在浏览器中授权后,账号下会生成一把 API 密钥并写入这个上游。

    客户端页中的 Codex 一行同时对应 Codex 命令行和 ChatGPT 桌面应用内置的 Codex,二者共用同一份配置;接管时一并说明,ChatGPT 桌面应用需要重启才会生效。

    修复:更新窗口底部的按钮被窗口边缘截断;在接管对话框中展开「完整改动」后,内容超出对话框。

  49. ThinkWatch Core v0.33.0 GitHub ↗
  50. ThinkWatch Core v0.32.0 GitHub ↗
  51. ThinkWatch Core v0.31.0 GitHub ↗
  52. ThinkWatch Core v0.30.0 GitHub ↗
  53. ThinkWatch Core v0.29.0 GitHub ↗
  54. ThinkWatch Lite v2026.9.9 中文 GitHub ↗

    升级须知:2026.9.8 及更早的版本无法在应用内安装这一版,需从发布页下载磁盘映像(DMG)手动安装一次,此后的版本可照常在应用内更新;通过 Homebrew 安装的执行 brew upgrade 即可。

    推理测速支持全部类型的上游。ChatGPT 账号上游此前无法测速,只会返回一句英文提示,现在与其余上游一样发送探测请求并测量首 token 时间,失败原因按界面语言显示。ChatGPT 账号不接受输出上限,费用预估中的上限显示为「不限」、金额显示为「按实际用量」,合计随之标为无法计算;会推理的模型在探测时留出推理所需的输出额度,避免上限过小导致没有可见的输出。

    发布内容只保留磁盘映像及其校验值,应用内更新改为下载与手动安装同一份磁盘映像。

  55. ThinkWatch Core v0.28.0 GitHub ↗
  56. ThinkWatch Lite v2026.9.8 中文 GitHub ↗

    升级须知:升级后本机的请求记录会清空。部分旧设置项已删除,配置文件中仍有这些项时网关无法启动,应用会进入安全模式;删除这些项后重新启动即可恢复。用 ChatGPT 账号登录的上游写入的「billing: subscription」属于这一类。

    界面支持英文,默认跟随系统语言,也可在「设置」中选择;菜单栏、系统通知和错误信息使用同一种语言。外观可选浅色、深色或跟随系统。

    导航重新整理为概览、流量、客户端、密钥、上游、路由、安全、MCP 和设置。

    流量页将请求与会话合为一张表,可按会话归组;每条请求标出发出它的密钥、应用和来源机器,请求详情中的 JSON 会格式化显示。概览的实时图显示最近十分钟,切换页面后保留所选的时间范围。

    路由页以密钥为中心重新设计,规则按顺序编辑;试算会给出请求使用的路由,不经过任何规则的请求也会如实说明。

    密钥单独成页,显示完整密钥并可复制,标出由哪个客户端接管生成,可安全地更换;每把密钥可设置可见模型和并发上限,全局并发限制已删除。客户端页用一张表列出各客户端的状态和所用密钥,未检测到的客户端也给出手动配置方法;「全部还原」此前只还原 opencode,现在会还原所有已接管的客户端。

    安全页只保留出站脱敏和工具调用审查两项全局防护,匹配规则可查看和自定义,并新增安全日志;MCP 扫描单独成页。

    计费方式只分按量计费和不计费,ChatGPT 等订阅账号也按价目表计算费用。上游行可直接展开模型列表,缺失的列表会自动获取;ChatGPT 账号上游显示邮箱和套餐,也可在另一台已登录的设备上输入代码完成登录。

    菜单栏改为原生样式,显示今日 token 和今日费用,菜单中可查看额度、进行中的请求和提醒。提醒只保留一个总开关,可标为已读;不再监测磁盘空间。

    网关监听的访问范围、网卡、端口与放行网段移入「设置」,保存后生效;日志保留时长也可在「设置」中调整。启动时显示启动画面,网关就绪后进入主界面,各页面随数据变化实时更新。

  57. ThinkWatch Core v0.27.0 GitHub ↗
  58. ThinkWatch Core v0.26.0 GitHub ↗
  59. ThinkWatch Core v0.25.0 GitHub ↗
  60. ThinkWatch Core v0.24.1 GitHub ↗
  61. ThinkWatch Core v0.24.0 GitHub ↗
  62. ThinkWatch Core v0.23.0 GitHub ↗
  63. ThinkWatch Core v0.22.0 GitHub ↗
  64. ThinkWatch Core v0.21.0 GitHub ↗
  65. ThinkWatch Core v0.20.0 GitHub ↗
  66. ThinkWatch Core v0.19.0 GitHub ↗
  67. ThinkWatch Core v0.18.0 GitHub ↗
  68. ThinkWatch Core v0.17.2 GitHub ↗
  69. ThinkWatch Core v0.17.1 GitHub ↗
  70. ThinkWatch Core v0.17.0 GitHub ↗
  71. ThinkWatch Core v0.16.0 GitHub ↗
  72. ThinkWatch Core v0.15.0 GitHub ↗
  73. ThinkWatch Core v0.14.1 GitHub ↗
  74. ThinkWatch Core v0.14.0 GitHub ↗
  75. ThinkWatch Core v0.13.0 GitHub ↗
  76. ThinkWatch Core v0.12.0 GitHub ↗
  77. ThinkWatch Core v0.11.1 GitHub ↗
  78. ThinkWatch Core v0.11.0 GitHub ↗
  79. ThinkWatch Core v0.10.0 GitHub ↗
  80. ThinkWatch Core v0.9.1 GitHub ↗
  81. ThinkWatch Core v0.9.0 GitHub ↗
  82. ThinkWatch Core v0.8.2 GitHub ↗
  83. ThinkWatch Core v0.8.1 GitHub ↗
  84. ThinkWatch Core v0.8.0 GitHub ↗
  85. ThinkWatch Core v0.7.0 GitHub ↗
  86. ThinkWatch Lite v2026.9.7 中文 GitHub ↗

    通知改为原生 macOS 通知:同一条会就地更新,过期的会自己撤回,点开直接跳到对应的页面。

  87. ThinkWatch Lite v2026.9.6 中文 GitHub ↗

    可以用 ChatGPT 账号新建上游:在浏览器中完成登录后,订阅额度即可供各类客户端使用。登录前会说明相关风险;账号的额度与额度重置卡在该上游的菜单中查看和使用。

    不同格式之间的请求可以互相转换:使用 Anthropic、OpenAI Chat、OpenAI Responses 或 Gemini 格式的客户端,可以使用其中任何一种格式的上游。请求详情中会显示转换情况与未能保留的字段。

    新增提醒:订阅额度用完、登录失效、上游拒绝凭据、代理不通、磁盘空间不足等情况记录在工具栏的提醒列表中,需要处理的会弹出系统通知。每一类提醒可在「设置」中选择弹出通知、仅在应用内显示或关闭。

    上游页重新设计:上游、代理与价目表分为三个标签,新建与编辑均在对话框中完成;上游可以设置自定义价目表与额外的请求头。

    请求列表会标出被取消的请求、部分失败的请求与缺少用量的请求。

  88. ThinkWatch Core v0.6.0 GitHub ↗
  89. ThinkWatch Core v0.5.0 GitHub ↗
  90. ThinkWatch Core v0.4.0 GitHub ↗
  91. ThinkWatch Core v0.3.0 GitHub ↗
  92. ThinkWatch Core v0.2.2 GitHub ↗
  93. ThinkWatch Core v0.2.1 GitHub ↗
  94. ThinkWatch Core v0.2.0 GitHub ↗
  95. ThinkWatch Core v0.1.2 GitHub ↗
  96. ThinkWatch Lite v2026.9.5 中文 GitHub ↗

    设置里的「自动检查新版本」只保留开关;检查改为每天一次。

  97. ThinkWatch Lite v2026.9.4 中文 GitHub ↗

    安装包的窗口重新设计:打开磁盘映像即可看到拖放方向和首次打开的说明。签名改为固定的证书,此后通过 Homebrew 升级不再提示签名者变化。

  98. ThinkWatch Lite v2026.9.3 中文 GitHub ↗

    有新版本时弹出更新窗口。网页下载安装的,按一次即可完成更新,并会等正在进行的请求结束后再重启;Homebrew 安装的,窗口给出更新命令,可一键复制。更新检查默认开启,可在「设置」中关闭。

  99. ThinkWatch Lite v2026.9.2 中文 GitHub ↗

    安装包改为磁盘映像:打开后把应用拖进「应用程序」即可。

  100. ThinkWatch Core v0.1.1 GitHub ↗
  101. ThinkWatch Lite v2026.9.1 中文 GitHub ↗

    这一版会在有新版本时告诉你。检查默认关闭,在「设置」里打开。

  102. ThinkWatch Lite v2026.9.0 GitHub ↗

    The first build. An unsigned arm64 .app with the gateway inside it.

  103. ThinkWatch Core v0.1.0 GitHub ↗
  104. 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 aud carries 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 in aud; Zitadel includes the parent project ID, and openidconnect’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 in aud regardless. 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#

    • azp is 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 with azp pointing 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 sets azp to 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_url trailing 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_err false positive in the MCP lifecycle stages, measured rather than boxed: the Ok variant is 296 bytes against the Err’s 152, so boxing saves nothing and adds an allocation.
    • UI — the brand mark stays inside the collapsed sidebar rail.

    Contributing#

    CONTRIBUTING.md and a PR template now state the branch contract that had only lived in docs/operations/release.md: routine work targets dev, and main is the release line. A workflow comments on PRs opened against main from anything other than dev or a hotfix/* 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

  1. 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 pinning 1.0.0 have no reason to bump; those tracking :latest move 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 via docker buildx imagetools create.
    • Node 24 opt-in — workflow sets FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true so all actions/* + docker/* run on Node 24 ahead of GitHub’s 2026-06-02 forced cutover.
  2. 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 :latest tag on the GHCR images can do so without surprise.

    Notes#

    • Docker images cut at this tag receive :latest for the first time — the release workflow suppresses :latest on 0.x and pre-release tags. Pin the version in production rather than tracking :latest unless you have a controlled rollback path.
  3. 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_logs now persists request_body + response_body (with byte counts and a body_capture_status dimension); mcp_logs persists the parallel tool_arguments + tool_result. ZSTD(6) compression keeps cold storage tractable; six dynamic config toggles (capture toggles per field + a global body_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 dereferences s3:// cells, so the console doesn’t know offload exists.

    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_bodies permission gates the body viewer in the console and the underlying /api/admin/{gateway,mcp}/logs/{id}/body endpoint, so the raw payload is preserved as legal record of intent while still requiring a separate grant to read.

    MCP downstream streaming#

    tools/call was 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 the Accept header — clients including text/event-stream receive 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-projection so 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 endpoint
    • GET /api/admin/models/{id}/routing-projection — current vs. auto split + projected $/1M tokens
    • GET /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_logs row stamps the actual upstream_model so 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_budget lifecycle 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_models allowlist 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 Surface trait. check_limits, check_access, check_budget, record_usage are now stages applied symmetrically on both surfaces, eliminating the historical drift between buffered, streaming, and short-circuit branches. The MCP proxy.rs and gateway proxy.rs both got split into submodules, and the 1667-line dashboard component on the frontend was carved into focused subcomponents.

  4. 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 /connections page 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

  1. 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 localStorage to 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.

  2. 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.