Skip to content

Commit 5eb1b87

Browse files
authored
Release 3.4.0 (#92)
Release 3.4.0: thinkwatch-core v0.69.0 (reasoning kept across formats, Gemini call signatures, tool calls written into text) and the forwarded-session fix.
1 parent cf6f7e8 commit 5eb1b87

7 files changed

Lines changed: 174 additions & 24 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,78 @@ target.
1111

1212
## [Unreleased]
1313

14+
## [3.4.0] — 2026-10-11
15+
16+
Conversations with reasoning models behind Chat-format upstreams keep their
17+
reasoning from turn to turn, Gemini's tool loops keep their thought
18+
signatures through a conversion, and a tool call that a Chat-format model
19+
writes into its answer text reaches the caller as a tool call. The
20+
thinkwatch-core crates move from v0.68.0 to v0.69.0.
21+
22+
### Read before upgrading
23+
24+
- **Converted answers from Chat-format upstreams can carry tool calls that
25+
arrived as text before.** Self-hosted and relayed Chat-format models
26+
(DeepSeek, GLM, Qwen, Llama and others) sometimes write a tool call into
27+
`content` in the template they were trained on instead of answering with
28+
`tool_calls`. On a route that converts the request to the Chat format,
29+
such a call to a tool the request defined now reaches the caller as a
30+
structured tool call, which the caller executes, where it used to be a
31+
tagged piece of prose the caller showed and did nothing with. Tool-call
32+
inspection sees these calls like any other and refuses or cuts them under
33+
the same rules; the request log's response body records them as tool
34+
calls, and a recovered call ends the answer with the tool-use stop reason
35+
instead of `stop`. A request that defined no tools, a name the request
36+
did not define, and a bare JSON object in prose stay text. A Chat caller
37+
on a Chat-format route is forwarded as sent and unchanged. No setting,
38+
schema, API route, Helm value or Redis key changes.
39+
40+
### Fixed
41+
42+
- **Gemini thought signatures are not counted as answer text.** When a
43+
request's usage has to be estimated (`usage_estimated`), the
44+
`thoughtSignature` on a Gemini thought or function call — an opaque blob
45+
of up to several kilobytes — was counted as output text; it is left out
46+
now, as the other formats' reasoning signatures were.
47+
48+
### Changed
49+
50+
- thinkwatch-core crates (tw-bedrock, tw-breaker, tw-dialect, tw-guard)
51+
v0.68.0 → v0.69.0. Only tw-dialect changes:
52+
- **Reasoning from a Chat-format upstream round-trips** (tw-dialect).
53+
Reasoning a Chat-format upstream returned (`reasoning_content`, or
54+
`reasoning` as OpenRouter and vLLM call it) was dropped from the next
55+
turn's request on every route, so a Claude Code or Codex conversation
56+
with tools against DeepSeek, which requires the reasoning of a
57+
tool-calling turn on the following requests of that turn, lost it every
58+
turn. Such reasoning is now carried in the caller's own format with a
59+
`tw1.ch.` signature, read back on the next turn, and written as
60+
`reasoning_content` only to a Chat-format upstream; to an upstream of
61+
another format it is left out as before, and reasoning another vendor
62+
signed is still not sent to a Chat-format upstream. Consecutive
63+
assistant messages are merged before a request is written in the Chat
64+
format, so a Responses caller's reasoning, text and tool call of one
65+
turn arrive as one message. The reasoning history a request carries
66+
back counts as its input, so the input tokens of those turns grow by
67+
it, as the upstream charges them.
68+
- **Gemini thought signatures on function calls are kept** (tw-dialect).
69+
Gemini with thinking puts a turn's thought signature on the first
70+
`functionCall` part, and the conversion only read signatures from
71+
thought parts, so a converted Gemini tool loop lost the signature and
72+
with it the model's reasoning continuity. The signature on a function
73+
call is now carried through a conversion and written back on the
74+
`functionCall` part; a call with no real signature gets the
75+
`skip_thought_signature_validator` placeholder as before. A Gemini
76+
request forwarded as sent that carries a signature from another route
77+
on a `functionCall` part keeps the part with the placeholder, where the
78+
part was removed and the tool loop broke.
79+
- **Tool calls written into a Chat-format upstream's answer text are
80+
recognised** (tw-dialect): see Read before upgrading. In this edition
81+
the answer assembled for the request log and the cache of a Chat
82+
caller's stream forwarded as sent to a Chat-format upstream keeps such
83+
a call as the text the caller received, rather than the structured call
84+
the conversion would have made of it.
85+
1486
## [3.3.1] — 2026-10-10
1587

1688
Streams are logged as they ended. A caller that hangs up once it has read
@@ -1696,7 +1768,8 @@ unreleased builds should: stop the gateway, run `db/schema.sql`
16961768
against PostgreSQL, restart against this tag. The schema is
16971769
idempotent end-to-end, so the apply is safe to repeat.
16981770

1699-
[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.3.1...HEAD
1771+
[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.4.0...HEAD
1772+
[3.4.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.4.0
17001773
[3.3.1]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.3.1
17011774
[3.3.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.3.0
17021775
[3.2.1]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.2.1

‎Cargo.lock‎

Lines changed: 14 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ members = [
1010
]
1111

1212
[workspace.package]
13-
version = "3.3.1"
13+
version = "3.4.0"
1414
edition = "2024"
1515
# The MSRV: the newest `rust-version` among the locked dependencies
1616
# (sqlx 0.9 and the aws-smithy crates under aws-sigv4). Without it,
@@ -70,10 +70,10 @@ opt-level = 3
7070
# never re-exported through a local shim. And the reverse: something only
7171
# this side uses (the at-rest crypto, IMDSv2 credentials, the gateway error)
7272
# lives here, not in core.
73-
tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.68.0" }
74-
tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.68.0" }
75-
tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.68.0" }
76-
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.68.0" }
73+
tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.69.0" }
74+
tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.69.0" }
75+
tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.69.0" }
76+
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.69.0" }
7777

7878
# Web framework
7979
axum = { version = "0.8", features = ["macros", "ws"] }

‎crates/gateway/src/proxy/generate.rs‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,13 @@ impl Outbound {
357357
/// compaction would be recorded as a failed one. A request the
358358
/// conversion layer cannot read at all can still be forwarded, and its
359359
/// answer is assembled knowing only the model and whether it streams.
360+
///
361+
/// A Chat upstream can write a tool call into its answer text instead
362+
/// of `tool_calls`. A converted answer turns such a call, when the
363+
/// request defined the tool, into a structured one (see
364+
/// `tw_dialect::chat::text_calls`); a forwarded answer reaches the
365+
/// caller as the upstream wrote it, text included, and is assembled as
366+
/// that (`Session::keep_text_calls`).
360367
fn forwarded_session(&self, body: &Value, model: &str, target: &Target) -> Session {
361368
let client = self.surface.dialect;
362369
let request =
@@ -368,7 +375,9 @@ impl Outbound {
368375
..Default::default()
369376
},
370377
};
371-
tw_dialect::convert::encode(&request, target).session
378+
tw_dialect::convert::encode(&request, target)
379+
.session
380+
.keep_text_calls()
372381
}
373382

374383
/// Address the request to `protocol`, naming `model` upstream.
@@ -1273,6 +1282,63 @@ mod tests {
12731282
assert!(converted.convert.as_ref().unwrap().is_compaction());
12741283
}
12751284

1285+
/// A Chat upstream that writes a tool call into its text: a converted
1286+
/// answer carries it as a structured call, a forwarded one is assembled
1287+
/// as the caller received it — text.
1288+
#[test]
1289+
fn a_tool_call_written_into_text_is_assembled_as_the_caller_received_it() {
1290+
const STREAM: &str = concat!(
1291+
"data: {\"id\":\"c\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,",
1292+
"\"delta\":{\"role\":\"assistant\",\"content\":\"<tool_call>{\\\"name\\\": \\\"ls\\\", ",
1293+
"\\\"arguments\\\": {\\\"path\\\": \\\"/\\\"}}</tool_call>\"}}]}\n\n",
1294+
"data: {\"id\":\"c\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,",
1295+
"\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
1296+
"data: [DONE]\n\n"
1297+
);
1298+
let tools = serde_json::json!([{
1299+
"type": "function",
1300+
"function": {"name": "ls", "parameters": {"type": "object"}}
1301+
}]);
1302+
let assemble = |session: &Session| -> Value {
1303+
let mut c = session.collector();
1304+
c.process(STREAM.as_bytes());
1305+
serde_json::from_slice(&c.finish().unwrap()).unwrap()
1306+
};
1307+
1308+
let ask = serde_json::json!({
1309+
"model": "m", "stream": true, "tools": tools,
1310+
"messages": [{"role": "user", "content": "list /"}]
1311+
});
1312+
let forwarded = outbound(CHAT, "/v1/chat/completions", ask, None)
1313+
.address(UpstreamProtocol::OpenAiChat, "m", false)
1314+
.unwrap_or_else(|e| panic!("{e:?}"));
1315+
assert!(forwarded.convert.is_none());
1316+
let message = &assemble(&forwarded.collect)["choices"][0]["message"];
1317+
assert!(
1318+
message["content"].as_str().unwrap().contains("<tool_call>"),
1319+
"{message}"
1320+
);
1321+
assert!(message.get("tool_calls").is_none(), "{message}");
1322+
1323+
let ask = serde_json::json!({
1324+
"model": "m", "stream": true, "max_tokens": 100,
1325+
"tools": [{"name": "ls", "input_schema": {"type": "object"}}],
1326+
"messages": [{"role": "user", "content": "list /"}]
1327+
});
1328+
let converted = outbound(MESSAGES, "/v1/messages", ask, None)
1329+
.address(UpstreamProtocol::OpenAiChat, "m", false)
1330+
.unwrap_or_else(|e| panic!("{e:?}"));
1331+
let content = &assemble(converted.convert.as_ref().unwrap())["content"];
1332+
assert!(
1333+
content
1334+
.as_array()
1335+
.unwrap()
1336+
.iter()
1337+
.any(|b| b["type"] == "tool_use" && b["name"] == "ls"),
1338+
"{content}"
1339+
);
1340+
}
1341+
12761342
/// Only breakpoints the conversion added may be taken out when an
12771343
/// upstream refuses them; the caller's own are its decision.
12781344
#[test]

‎crates/gateway/src/usage_estimate.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ fn text_len(v: &Value, skip: &[&str]) -> usize {
9191
"call_id",
9292
"tool_call_id",
9393
"signature",
94+
"thoughtSignature",
95+
"thought_signature",
9496
"encrypted_content",
9597
];
9698
match v {
@@ -175,6 +177,15 @@ mod tests {
175177
],
176178
});
177179
assert_eq!(answer_tokens(body.to_string().as_bytes()), 2);
180+
// Gemini's, on a thought and on the function call it led to.
181+
let body = json!({
182+
"candidates": [{"content": {"parts": [
183+
{"text": "abcd", "thought": true, "thoughtSignature": "x".repeat(4000)},
184+
{"functionCall": {"name": "ls", "args": {}}, "thoughtSignature": "x".repeat(4000)},
185+
{"functionCall": {"name": "ls", "args": {}}, "thought_signature": "x".repeat(4000)}
186+
]}}],
187+
});
188+
assert_eq!(answer_tokens(body.to_string().as_bytes()), 2);
178189
}
179190

180191
#[test]

‎deploy/helm/think-watch/Chart.yaml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ apiVersion: v2
22
name: think-watch
33
description: Enterprise AI API Gateway & MCP Management Platform
44
type: application
5-
version: 3.3.1
6-
appVersion: "3.3.1"
5+
version: 3.4.0
6+
appVersion: "3.4.0"
77
keywords:
88
- ai
99
- gateway

‎web/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "web",
33
"private": true,
4-
"version": "3.3.1",
4+
"version": "3.4.0",
55
"type": "module",
66
"packageManager": "pnpm@12.9.1",
77
"scripts": {

0 commit comments

Comments
 (0)