Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 19 additions & 19 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────
Expand Down
31 changes: 30 additions & 1 deletion crates/tw-dialect/src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 = "<tool_call>{\"name\": \"Read\", \"arguments\": {}}</tool_call>";
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,
Expand Down
28 changes: 28 additions & 0 deletions release-notes/0.69.0.md
Original file line number Diff line number Diff line change
@@ -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 `<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.
Loading