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.