Repository navigation
ThinkWatch Core 0.63.0
This release gives routing more to go on and keys a budget. Load-balance groups can weigh their members and favour faster or more reliable upstreams; a stream that is slow to start can move to the next upstream; an upstream can be capped at a number of concurrent requests; an upstream's model can have its context window and output limit set by hand; and a gateway key can have usage limits per minute, hour, day, week or month. Each turn on a Responses WebSocket now counts as a request of its own.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) goes from 38 to 39. ThinkWatch Lite connects only to a core with the same protocol version: ThinkWatch Lite 2026.10.5 includes 0.62.0 (protocol 38) and does not connect to 0.63.0. A server used with it stays on 0.62.0 until the app is updated to a release that includes 0.63.0;sudo twcore upgrade --version 0.62.0 --restartswitches a server back. - The request store's schema is unchanged: upgrading from 0.62.0 keeps the request history.
- The configuration format only gains fields, and every 0.62.0 configuration loads, with one exception: a group that lists the same upstream twice, or lists a name that is not an upstream, is now refused when the configuration loads (it always was when saved through the control plane). Such a configuration starts in safe mode, which names the group and the member.
url-testordering can change: its samples now measure the time from sending a request to an upstream to the first content of the answer (see below).- A gateway key's
max_concurrentnow also counts streamed answers until they end; before, a request left the count when the response started. A key at its limit therefore queues more than before. - Responses WebSocket connections are recorded one row per turn instead of one row per connection.
- Changed in the protocol:
- Groups:
GroupViewgainsweights(every member's weight for a load-balance group, empty otherwise) andbalance_by(BalanceBy);GroupInputtakes both (weightsmissing = all 1). The dry-run'sDryRunCandidategainsweight,ttfb_ms,success_rateandbalance_factor;DryRunResultgainsbalance_by. - Upstreams:
ProviderViewandProviderInputgainmax_concurrent.ModelRowgainscontext_window_source,max_output_tokensandmax_output_tokens_source(SpecSource:price_tableormanual). New:PUT /provider-model-spec(ModelSpecSave→ConfigWritten). - Failover:
FailoverViewgainsnext_on_slow_startandslot_wait_secs. - Attempts:
AttemptViewgainsqueued_ms,skipped(ServeSkip::Busy) andusage(AttemptUsage);AttemptOutcomegainsslow_start. - Keys:
ClientViewgainslimits(KeyLimitView) andunpriced_models;KeyInputgainslimits(KeyLimitInput;LimitPer,LimitMeasure). New event:Event::KeyLimitAlert. - A client written for protocol 38 drops weights and limits when it saves a group or a key.
- Groups:
- Message codes, compared with 0.62.0:
- New:
config.key_limit_cache_reads,config.key_limit_cost_too_small,config.key_limit_duplicate,config.key_limit_empty,config.key_limit_not_positive,config.key_limit_retention,config.key_limit_two_measures,config.model_spec_blank_model,config.model_spec_empty,config.model_spec_wildcard,config.model_spec_zero,config.provider_concurrency_range,config.slow_start_too_short;control.group.balance_not_load_balance,control.group.weight_not_load_balance,control.group.weight_not_member,control.group.weight_out_of_range;engine.group_balance_not_load_balance,engine.group_unknown_upstream,engine.group_upstream_twice,engine.group_weight_not_load_balance,engine.group_weight_out_of_range;gw.busy_all,gw.busy_upstream,gw.key_limit.cost_per_period,gw.key_limit.cost_rolling,gw.key_limit.requests_per_period,gw.key_limit.requests_rolling,gw.key_limit.tokens_per_period,gw.key_limit.tokens_rolling,gw.slow_start,gw.ws.upstream_closed. - None removed.
- New:
Weights and distribution in load-balance groups. A member of a load-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_byadjusts the weights automatically:latencygives faster upstreams more,healthgives upstreams that fail less more,latency-healthboth;weights(the default) uses the weights alone. The factors multiply the weights. An upstream without enough measurements counts as average, so a new one is tried without being flooded, and a flaky one still gets the occasional request, so its recovery is noticed.- The dry-run shows each member's weight, time to first content, success rate and factor, and predicts the next upstream exactly as the gateway will choose it.
- WebSocket upgrades are now ordered the same way as HTTP requests.
What speed means. The samples behind url-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: truecounts cache reads too. Cost is in US dollars, at least 0.01; models without a price and upstreams withbilling: freecount as 0. - A request in flight reserves its estimate, and its recorded usage replaces the estimate when it ends. A request that never reached an upstream counts for nothing.
- Totals are rebuilt from the request store at startup, and when a period's start moves, such as after a time-zone change. The store must keep rows for the whole period (
retention.row_daysat least 1, 7 or 31 days). - A day, week or month limit that is used up answers 429 with
Retry-Afteruntil the reset andx-should-retry: false, so the official SDKs don't retry it; OpenAI-format bodies carryinsufficient_quota. A minute or hour limit waits within the request's wait, then answers 429 with the exact time to retry. - The control plane reports each limit's usage and reset time, and the models a key with a cost limit can reach that have no price.
Event::KeyLimitAlertfires at 80% and when a limit is reached.
WebSocket turns. Each response.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.
Downloads
| Platform | Binary | Archive for server installation |
|---|---|---|
| Linux, x86_64 | twcore-x86_64-unknown-linux-gnu |
twcore-x86_64-unknown-linux-gnu.tar.gz |
| Linux, aarch64 | twcore-aarch64-unknown-linux-gnu |
twcore-aarch64-unknown-linux-gnu.tar.gz |
| macOS, Apple silicon | twcore-aarch64-apple-darwin |
— |
| Windows, x64 | twcore-x86_64-pc-windows-msvc.exe |
— |
| Windows, ARM64 | twcore-aarch64-pc-windows-msvc.exe |
— |
Each file is published with a .sha256 file beside it. A Linux archive contains twcore, the systemd unit twcore.service and LICENSE. ThinkWatch Lite includes its own copy of twcore; the files here are for running core separately, such as on a server.
Server installation
On Linux (x86_64 or aarch64), the install script sets up twcore as a systemd service. This installs 0.63.0:
curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh -s -- --version 0.63.0An installation made with the script switches to 0.63.0 with:
sudo twcore upgrade --version 0.63.0 --restartConfiguration, the remote control port and connecting ThinkWatch Lite are described in docs/server.md.
Verifying a download
A .sha256 file holds the SHA-256 of the file followed by its name. With both files in the current directory, on Linux:
sha256sum -c twcore-x86_64-unknown-linux-gnu.tar.gz.sha256On macOS:
shasum -a 256 -c twcore-aarch64-apple-darwin.sha256On Windows, in PowerShell, the following prints True when the binary matches:
(Get-FileHash .\twcore-x86_64-pc-windows-msvc.exe).Hash -eq (Get-Content .\twcore-x86_64-pc-windows-msvc.exe.sha256).Split()[0]The install script and twcore upgrade check the SHA-256 themselves.
What's Changed
- Configuration validation refuses a group member listed twice or that is not an upstream by @fylorn in #291
- Load-balance weights and distribution by speed and reliability, slow-start switch, upstream concurrency, model specs, key usage limits, per-turn WebSocket requests by @fylorn in #292
- chore: v0.63.0 by @fylorn in #293
Full Changelog: v0.62.0...v0.63.0