From 54642187659dd6093d1f7dc07c25802294116d3b Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:03:07 +0800 Subject: [PATCH 1/3] v0.69.0 Reasoning kept across API formats (Chat reasoning_content, Gemini functionCall signatures), tool calls written into answer text, compressed request bodies and upstream answers, keepalive while a request waits for admission, routing rules by time of day, and each session turn's context window with what fills it. Protocol 45. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 38 +++++++++++++++++++------------------- Cargo.toml | 2 +- release-notes/0.69.0.md | 28 ++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 20 deletions(-) create mode 100644 release-notes/0.69.0.md diff --git a/Cargo.lock b/Cargo.lock index 88d63a6f..11a574cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3143,7 +3143,7 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.68.0" +version = "0.69.0" dependencies = [ "serde", "serde_json", @@ -3154,7 +3154,7 @@ dependencies = [ [[package]] name = "tw-bedrock" -version = "0.68.0" +version = "0.69.0" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -3175,7 +3175,7 @@ dependencies = [ [[package]] name = "tw-breaker" -version = "0.68.0" +version = "0.69.0" dependencies = [ "serde", "serde_json", @@ -3183,7 +3183,7 @@ dependencies = [ [[package]] name = "tw-config" -version = "0.68.0" +version = "0.69.0" dependencies = [ "blake3", "libc", @@ -3208,7 +3208,7 @@ dependencies = [ [[package]] name = "tw-control" -version = "0.68.0" +version = "0.69.0" dependencies = [ "axum", "base64", @@ -3251,7 +3251,7 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.68.0" +version = "0.69.0" dependencies = [ "memchr", "serde", @@ -3260,7 +3260,7 @@ dependencies = [ [[package]] name = "tw-engine" -version = "0.68.0" +version = "0.69.0" dependencies = [ "chrono", "serde", @@ -3274,7 +3274,7 @@ dependencies = [ [[package]] name = "tw-gateway" -version = "0.68.0" +version = "0.69.0" dependencies = [ "arc-swap", "async-stream", @@ -3325,7 +3325,7 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.68.0" +version = "0.69.0" dependencies = [ "base64", "bytes", @@ -3341,7 +3341,7 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.68.0" +version = "0.69.0" dependencies = [ "serde", "serde_json", @@ -3355,7 +3355,7 @@ dependencies = [ [[package]] name = "tw-observe" -version = "0.68.0" +version = "0.69.0" dependencies = [ "tokio", "tracing", @@ -3364,7 +3364,7 @@ dependencies = [ [[package]] name = "tw-plugin" -version = "0.68.0" +version = "0.69.0" dependencies = [ "libc", "rand 0.10.2", @@ -3379,7 +3379,7 @@ dependencies = [ [[package]] name = "tw-pricing" -version = "0.68.0" +version = "0.69.0" dependencies = [ "arc-swap", "flate2", @@ -3392,14 +3392,14 @@ dependencies = [ [[package]] name = "tw-secret" -version = "0.68.0" +version = "0.69.0" dependencies = [ "thiserror", ] [[package]] name = "tw-store" -version = "0.68.0" +version = "0.69.0" dependencies = [ "blake3", "bytes", @@ -3419,7 +3419,7 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.68.0" +version = "0.69.0" dependencies = [ "serde", "serde_json", @@ -3428,7 +3428,7 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.68.0" +version = "0.69.0" dependencies = [ "libc", "notify", @@ -3439,7 +3439,7 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.68.0" +version = "0.69.0" dependencies = [ "saphyr-parser", "serde_yaml_ng", @@ -3449,7 +3449,7 @@ dependencies = [ [[package]] name = "twcore" -version = "0.68.0" +version = "0.69.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 750a82c2..dfd707b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ categories = ["network-programming", "web-programming::http-server"] # 二进制走 CalVer,crate 走 SemVer —— 两者是两回事,卖给不同的人。 # 这里是 crate 的版本。 -version = "0.68.0" +version = "0.69.0" [workspace.dependencies] # ── 内部 crate ─────────────────────────────────────────── diff --git a/release-notes/0.69.0.md b/release-notes/0.69.0.md new file mode 100644 index 00000000..b3f4235f --- /dev/null +++ b/release-notes/0.69.0.md @@ -0,0 +1,28 @@ +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 `{…}`, DeepSeek's `<|tool▁calls▁begin|>` block, GLM's ``/`` form and `` 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. + +**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. From 2883e5d99f734d69e1d7da173684bab29238fdf4 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:06:49 +0800 Subject: [PATCH 2/3] Release notes: describe DeepSeek's markers in ASCII Co-Authored-By: Claude Fable 5.1 --- release-notes/0.69.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-notes/0.69.0.md b/release-notes/0.69.0.md index b3f4235f..2e766628 100644 --- a/release-notes/0.69.0.md +++ b/release-notes/0.69.0.md @@ -17,7 +17,7 @@ This release keeps a model's reasoning intact across API formats, recognises too - 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 `{…}`, DeepSeek's `<|tool▁calls▁begin|>` block, GLM's ``/`` form and `` 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. +**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 `{…}`, DeepSeek's own tool-call block markers, GLM's ``/`` form and `` 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. **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. From 4524f3788375d00823c7f33910cd8f80374991fb Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:21:43 +0800 Subject: [PATCH 3/3] Session::keep_text_calls: leave tool calls written into text as text A program that embeds tw-dialect and only forwards a stream records a copy of what its client received; with the text-call recognition on, that copy would hold a tool call the client never saw. The switch clears the tool names the recognition keys on, for the whole response and the streamed path alike. Co-Authored-By: Claude Fable 5.1 --- crates/tw-dialect/src/convert.rs | 31 ++++++++++++++++++++++++++++++- release-notes/0.69.0.md | 2 +- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/tw-dialect/src/convert.rs b/crates/tw-dialect/src/convert.rs index bd1d64c6..37743f60 100644 --- a/crates/tw-dialect/src/convert.rs +++ b/crates/tw-dialect/src/convert.rs @@ -356,8 +356,17 @@ impl Session { } } + /// **不**把 Chat 上游写进正文的工具调用换成调用块。 + /// + /// 原样转发的流,客户端收到的就是那段正文;照着它记的副本(日志、缓存)不该比客户端 + /// 多出一个调用来。只转发、不转换的一方在拿到会话之后调一次这个 + pub fn keep_text_calls(mut self) -> Session { + self.tools.clear(); + self + } + /// 自由格式工具的输入:别家上游把它包在 `{"input": …}` 里,拆出来。Chat 上游写进 - /// 正文的工具调用先换成调用块 + /// 正文的工具调用先换成调用块(除非 [`Session::keep_text_calls`]) fn normalize(&self, r: &mut Response) { if self.upstream == Dialect::Chat { chat::text_calls::rewrite(r, &self.tools); @@ -1178,6 +1187,26 @@ mod tests { use super::*; use serde_json::json; + /// 只转发、不转换的一方:客户端收到的是正文,照着它记的副本也要是正文 + #[test] + fn keep_text_calls_leaves_a_written_call_as_text() { + let text = "{\"name\": \"Read\", \"arguments\": {}}"; + let fresh = || Response { + blocks: vec![Block::Text(text.into())], + stop: Some(StopReason::EndTurn), + ..Default::default() + }; + let mut s = Session::for_test(Dialect::Anthropic, Dialect::Chat); + s.tools.insert("Read".into()); + let mut r = fresh(); + s.normalize(&mut r); + assert!(matches!(&r.blocks[0], Block::ToolCall(c) if c.name == "Read")); + let s = s.keep_text_calls(); + let mut r = fresh(); + s.normalize(&mut r); + assert_eq!(r.blocks[0], Block::Text(text.into())); + } + fn target(d: Dialect) -> Target { Target { dialect: d, diff --git a/release-notes/0.69.0.md b/release-notes/0.69.0.md index 2e766628..0e24588c 100644 --- a/release-notes/0.69.0.md +++ b/release-notes/0.69.0.md @@ -17,7 +17,7 @@ This release keeps a model's reasoning intact across API formats, recognises too - 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 `{…}`, DeepSeek's own tool-call block markers, GLM's ``/`` form and `` 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. +**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 `{…}`, DeepSeek's own tool-call block markers, GLM's ``/`` form and `` 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.