Skip to content

Commit 4c6f35f

Browse files
authored
Release 3.3.0 (#89)
See CHANGELOG.md [3.3.0].
1 parent 40f9c5f commit 4c6f35f

16 files changed

Lines changed: 853 additions & 88 deletions

File tree

‎CHANGELOG.md‎

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

1212
## [Unreleased]
1313

14+
## [3.3.0] — 2026-10-09
15+
16+
Requests converted for Claude now use prompt caching, and Codex works
17+
through routes that convert its requests to another format: its tools,
18+
its history and its compactions arrive. A Responses request that only
19+
OpenAI can read — one that continues a conversation kept on OpenAI's
20+
servers, or carries a compaction OpenAI wrote — now goes to a route that
21+
speaks Responses instead of being refused. The thinkwatch-core crates move
22+
from v0.65.0 to v0.67.1.
23+
24+
### Read before upgrading
25+
26+
- **Converted requests to Claude are cached, and billed as cache traffic.**
27+
A request converted for an Anthropic-format upstream, or for a Claude
28+
model on Bedrock that AWS lists for prompt caching (Claude 3.5 Sonnet v2,
29+
Claude 3.7 Sonnet, and Claude 4.5 and later), from a client that marks no
30+
cache breakpoints — Codex, Chat and Gemini clients, and an Anthropic
31+
client whose request goes to Bedrock without `cache_control` — now gets
32+
them at the end of the tools, the system prompt and the last two user
33+
turns, with the 5-minute lifetime.
34+
- The upstream bills the prefix a request writes at its cache-write rate
35+
(1.25× input at Anthropic) and what the next turns read back at its
36+
cache-read rate (0.1×). ThinkWatch prices and counts them the same
37+
way: cost, budgets and `tokens` rate limits weigh a cache write by the
38+
model's `cache_write_weight` and a read by its `cache_read_weight`,
39+
1.25× and 0.1× its input weight when unset. Set them on the model to
40+
match what your upstream charges.
41+
- A conversation of several turns costs less. A one-off request with a
42+
long prompt costs up to a quarter more on its input.
43+
- Prompt token counts (`input_tokens` on the log rows, quotas) include
44+
cached input in full, as before.
45+
- Requests that mark their own breakpoints, such as Claude Code's, and
46+
requests forwarded in their own format are unchanged.
47+
- An upstream that refuses the added breakpoints — a `400` that names
48+
`cache_control`, `cachePoint` or prompt caching, as an
49+
Anthropic-compatible endpoint that does not know them answers — gets
50+
the request once more without them, and later requests to it for that
51+
model leave them out from the start. Each instance remembers this
52+
until a provider or model is changed or it restarts. Breakpoints a
53+
client marked itself are never taken out, and the probes that check a
54+
route's API format go without breakpoints.
55+
- **Rolling back with Codex sessions.** A Codex session that compacted
56+
through a converted route on 3.3.0 carries a compaction that 3.2.1
57+
refuses, so after a rollback, or on a 3.2.1 instance during the rollout,
58+
it cannot continue and needs a new session.
59+
60+
Nothing else needs action: no setting, schema, API route or Redis key
61+
changes.
62+
63+
### Fixed
64+
65+
- **Responses requests that only OpenAI can read.** A Responses request
66+
that continues a conversation kept on OpenAI's servers
67+
(`previous_response_id`, `conversation`, `prompt`, `background`), points
68+
at a stored item (`item_reference`) or carries a compaction OpenAI wrote
69+
was refused with `400`, even on a route that speaks Responses and would
70+
have forwarded it as sent: every request was decoded for its usage
71+
estimate, and one that could not be decoded was refused. Codex sends such
72+
a compaction in every request after compacting a session through an
73+
OpenAI upstream, so the session could not go on; and a WebSocket turn
74+
naming a response other than the connection's last one, meant to go
75+
upstream as sent, was refused too. These requests now go to the model's
76+
routes that speak Responses, and only a model with none refuses them,
77+
with the reason. Their input estimate counts the request's text.
78+
79+
### Changed
80+
81+
- thinkwatch-core crates (tw-bedrock, tw-breaker, tw-dialect, tw-guard)
82+
v0.65.0 → v0.67.1. Only tw-dialect, the format conversion, changes:
83+
- **Prompt caching for Claude** on converted requests (see Read before
84+
upgrading).
85+
- **Codex tools.** For some models Codex declares every tool in an
86+
`additional_tools` input item and sends no top-level `tools`. Those
87+
items were dropped, so a route converting to Anthropic, Chat, Gemini
88+
or Bedrock sent no tools and Codex could not read files or run
89+
commands. Every tool now arrives, and tool calls come back under the
90+
names and kinds Codex declared. Local shell calls, tool search,
91+
reasoning-effort changes and agent messages in the history are
92+
converted instead of dropped.
93+
- **Codex compaction on converted routes.** Codex compacts a long
94+
session through a provider named `OpenAI` by asking for exactly one
95+
compaction item, which a converted route could not give, so compacting
96+
failed. The upstream now writes a handoff summary, Codex receives it as
97+
its compaction, and later requests carry it back. The summary request
98+
is billed like any other. The summary travels in the item's
99+
`encrypted_content` (`tw1.c.…`) base64-encoded, not encrypted; with
100+
`audit.body_redact_pii` on, captured bodies have it redacted like the
101+
rest of the body. OpenAI's own compactions still cannot be read by an
102+
upstream of another format, and are refused saying so.
103+
- **System messages in mid-conversation stay in place.** A `system` or
104+
`developer` message after the conversation has started was added to
105+
the system prompt on a converted route. It now stays where it was
106+
given: a developer message for a Responses upstream, and a user turn
107+
wrapped in `<system-reminder>` for Anthropic, Chat, Gemini and Bedrock
108+
upstreams. The system prompt stays the same from one turn to the next,
109+
which is what keeps the prompt cache working.
110+
- **`verbosity`** (Chat) and **`text.verbosity`** (Responses) carry
111+
across the conversion to GPT-5 and later models. Other models still
112+
leave it out.
113+
14114
## [3.2.1] — 2026-10-09
15115

16116
The Helm chart and the Compose file now pull the published images: they
@@ -1469,7 +1569,8 @@ unreleased builds should: stop the gateway, run `db/schema.sql`
14691569
against PostgreSQL, restart against this tag. The schema is
14701570
idempotent end-to-end, so the apply is safe to repeat.
14711571

1472-
[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.2.1...HEAD
1572+
[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.3.0...HEAD
1573+
[3.3.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.3.0
14731574
[3.2.1]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.2.1
14741575
[3.2.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.2.0
14751576
[3.1.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.1.0

‎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.2.1"
13+
version = "3.3.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.65.0" }
74-
tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.65.0" }
75-
tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.65.0" }
76-
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.65.0" }
73+
tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.67.1" }
74+
tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.67.1" }
75+
tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.67.1" }
76+
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.67.1" }
7777

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

‎crates/common/Cargo.toml‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ edition.workspace = true
77
tw-breaker = { workspace = true }
88
tw-guard = { workspace = true }
99
tw-bedrock = { workspace = true }
10+
# Reads the compaction summaries a conversion carries (see `pii`).
11+
tw-dialect = { workspace = true }
1012
axum = { workspace = true }
1113
sqlx = { workspace = true }
1214
fred = { workspace = true }
@@ -46,6 +48,3 @@ aws-sigv4 = { workspace = true }
4648
aws-credential-types = { workspace = true }
4749
http_1x = { package = "http", version = "1" }
4850
async-trait = "0.1"
49-
50-
[dev-dependencies]
51-
tw-dialect = { workspace = true }

‎crates/common/src/pii.rs‎

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
//! thinkwatch-core's (`tw_guard::redact`), the engine the desktop gateway
1515
//! redacts with.
1616
17+
use std::borrow::Cow;
1718
use std::sync::Arc;
1819

1920
use tw_guard::policy::RedactPolicy;
@@ -27,22 +28,70 @@ use tw_guard::redact::rules::RuleSet;
2728
/// JSON escapes are read, a custom rule's match stays inside one JSON
2829
/// string so the result is still JSON, base64 payloads are left alone,
2930
/// and so are the gateway's own placeholders (a captured answer still
30-
/// carries them).
31+
/// carries them). The one base64 payload that is read is a compaction
32+
/// summary a conversion carries (see [`redact_carried_summaries`]).
3133
pub fn redact_blob(rules: &RuleSet, input: &str) -> String {
3234
if rules.is_empty() {
3335
return input.to_string();
3436
}
35-
let hits = tw_guard::redact::flow::hits(input, rules);
36-
let mut out = input.to_string();
37+
let input = redact_carried_summaries(rules, input);
38+
let hits = tw_guard::redact::flow::hits(&input, rules);
39+
let mut out = input.into_owned();
3740
for h in hits.iter().rev() {
38-
out.replace_range(
39-
h.bytes.clone(),
40-
&format!("{{{{REDACTED_{}}}}}", h.rule.id()),
41-
);
41+
out.replace_range(h.bytes.clone(), &redacted(&h.rule));
4242
}
4343
out
4444
}
4545

46+
fn redacted(rule: &tw_guard::redact::rules::Rule) -> String {
47+
format!("{{{{REDACTED_{}}}}}", rule.id())
48+
}
49+
50+
/// The summaries in `input` that a conversion carries, redacted.
51+
///
52+
/// A Codex compaction sent to an upstream of another format comes back as
53+
/// the summary that upstream wrote — the conversation restated, with its
54+
/// paths, commands and values — base64-encoded in the item's
55+
/// `encrypted_content` (`tw1.c.…`, see `tw_dialect::compaction`), and
56+
/// later requests carry it back. Unlike OpenAI's own compactions it is not
57+
/// encrypted, but as base64 the search above passes over it. Each one is
58+
/// read, redacted as plain text and written back the same way, so the
59+
/// body keeps its shape.
60+
fn redact_carried_summaries<'a>(rules: &RuleSet, input: &'a str) -> Cow<'a, str> {
61+
const PREFIX: &str = tw_dialect::compaction::CARRIED_PREFIX;
62+
if !input.contains(PREFIX) {
63+
return Cow::Borrowed(input);
64+
}
65+
let mut out = String::with_capacity(input.len());
66+
let mut rest = input;
67+
while let Some(at) = rest.find(PREFIX) {
68+
let start = at + PREFIX.len();
69+
let end = rest[start..]
70+
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
71+
.map_or(rest.len(), |n| start + n);
72+
let carried = &rest[at..end];
73+
out.push_str(&rest[..at]);
74+
match tw_dialect::compaction::read(carried) {
75+
Some(summary) => {
76+
let hits = tw_guard::redact::flow::hits_plain(&summary, rules);
77+
if hits.is_empty() {
78+
out.push_str(carried);
79+
} else {
80+
let mut text = summary;
81+
for h in hits.iter().rev() {
82+
text.replace_range(h.bytes.clone(), &redacted(&h.rule));
83+
}
84+
out.push_str(&tw_dialect::compaction::carry(&text));
85+
}
86+
}
87+
None => out.push_str(carried),
88+
}
89+
rest = &rest[end..];
90+
}
91+
out.push_str(rest);
92+
Cow::Owned(out)
93+
}
94+
4695
/// The at-rest redactor, hot-swapped with the outbound redaction policy.
4796
#[derive(Clone)]
4897
pub struct BlobRedactor {
@@ -133,6 +182,31 @@ mod tests {
133182
assert_eq!(v["b"], "keep", "{out}");
134183
}
135184

185+
#[test]
186+
fn a_carried_compaction_summary_is_redacted_where_it_is() {
187+
use tw_dialect::compaction::{carry, read};
188+
let rules = only(&[("EMAIL", r"[\w.]+@[\w.]+")]);
189+
let carried = carry("Reply to alice@example.com about src/lib.rs.");
190+
let body = format!(
191+
r#"{{"input":[{{"type":"compaction","encrypted_content":"{carried}"}},{{"role":"user","content":"bob@example.com"}}]}}"#
192+
);
193+
let out = redact_blob(&rules, &body);
194+
let v: serde_json::Value = serde_json::from_str(&out).expect("still JSON");
195+
let summary = read(v["input"][0]["encrypted_content"].as_str().unwrap());
196+
assert_eq!(
197+
summary.as_deref(),
198+
Some("Reply to {{REDACTED_EMAIL}} about src/lib.rs."),
199+
"{out}"
200+
);
201+
assert_eq!(v["input"][1]["content"], "{{REDACTED_EMAIL}}", "{out}");
202+
203+
// Nothing found in it, or not one of ours: left as it was.
204+
let clean = format!(r#"{{"encrypted_content":"{}"}}"#, carry("tests pass"));
205+
assert_eq!(redact_blob(&rules, &clean), clean);
206+
let theirs = r#"{"encrypted_content":"gAAAAABoQ2xpZW50"}"#;
207+
assert_eq!(redact_blob(&rules, theirs), theirs);
208+
}
209+
136210
#[test]
137211
fn the_policys_rules_are_the_ones_used_built_in_and_custom() {
138212
let key = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAA";

0 commit comments

Comments
 (0)