diff --git a/Cargo.lock b/Cargo.lock
index 88d63a6..11a574c 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 750a82c..dfd707b 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/crates/tw-dialect/src/convert.rs b/crates/tw-dialect/src/convert.rs
index bd1d64c..37743f6 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
new file mode 100644
index 0000000..0e24588
--- /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 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.
+
+**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.