Skip to content

Commit ada6278

Browse files
authored
Release 3.9.1 (#121)
See CHANGELOG.md [3.9.1].
1 parent ac81703 commit ada6278

14 files changed

Lines changed: 1301 additions & 39 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,50 @@ target.
1111

1212
## [Unreleased]
1313

14+
## [3.9.1] — 2026-10-23
15+
16+
A conversation whose history a provider encrypted, such as a Codex
17+
conversation over OpenAI's Responses API, now stays with that provider
18+
instead of failing on every turn once it moves, and a provider that
19+
cannot read such history says why. The thinkwatch-core crates move from
20+
v0.77.0 to v0.81.0.
21+
22+
No setting, schema or API route changes.
23+
24+
### Fixed
25+
26+
- **A conversation whose history a provider encrypted goes back to that
27+
provider.** OpenAI's Responses API with `store: false` (Codex always
28+
sends it) hands the client encrypted state that comes back every turn:
29+
reasoning, remote compactions, Codex's messages between agents,
30+
encrypted tool output. Only the account that encrypted it can read it,
31+
so a conversation that moved to another provider — after five idle
32+
minutes, a tripped breaker or a failover — was refused on every turn
33+
from then on with `invalid_encrypted_content`. The gateway now
34+
remembers, for 30 days and whatever the affinity settings say, the
35+
provider that last answered each conversation (the authenticated user
36+
and the request's `x-session-id`, or else the session header the client
37+
sends by itself, such as Codex's `session_id`; over HTTP and over the
38+
WebSocket). The log row's `session_id` still comes from `x-session-id`
39+
only. A
40+
Responses request carrying encrypted content that cannot be left out
41+
goes only to that provider: no strategy pick, no failover, and its open
42+
breaker does not stop it. When that provider has no route for the model
43+
any more, the request is refused up front with 409, naming the provider
44+
and saying to start a new conversation. A request carrying only
45+
encrypted reasoning goes to that provider first and still fails over.
46+
Requests that name no conversation are routed as before. The request's log
47+
row records `"pinned_by": "encrypted_history"` when the provider was
48+
picked this way.
49+
- **A provider that cannot read a conversation's encrypted history says
50+
so.** Its refusal, as a 4xx answer or as the error event of a stream
51+
that opened with 200, now reaches the caller and the log as
52+
`SealedHistoryRefused`: the provider cannot read this conversation's
53+
encrypted history because another provider encrypted it; route the
54+
conversation back to that provider or start a new conversation. The
55+
upstream's own words follow. It no longer counts against the route's
56+
breaker.
57+
1458
## [3.9.0] — 2026-10-20
1559

1660
The Add to ThinkWatch Lite link from a rotated key now carries the key's

‎Cargo.lock‎

Lines changed: 15 additions & 15 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.9.0"
13+
version = "3.9.1"
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.77.0" }
74-
tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.77.0" }
75-
tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.77.0" }
76-
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.77.0" }
73+
tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" }
74+
tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" }
75+
tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" }
76+
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" }
7777

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

‎crates/gateway/src/error.rs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,32 @@ pub enum GatewayError {
6969
/// pass it on. Maps to 403.
7070
#[error("Blocked by policy: {0}")]
7171
PolicyBlocked(String),
72+
/// The upstream refused the conversation's encrypted history (OpenAI
73+
/// Responses `encrypted_content`, see `tw_dialect::sealed`): another
74+
/// provider's account encrypted it, and only that one can read it.
75+
/// `status` is the upstream's (as an answer, or what its error in a
76+
/// stream would have been), `said` its own words, truncated. Not the
77+
/// upstream failing: no failover, nothing against its breaker.
78+
#[error(
79+
"Provider `{provider}` cannot read this conversation's encrypted history: another \
80+
provider encrypted it, and only that one can read it. Route the conversation back to \
81+
that provider, or start a new conversation. The provider said: {said}"
82+
)]
83+
SealedHistoryRefused {
84+
status: u16,
85+
provider: String,
86+
said: String,
87+
},
88+
/// The conversation's history is encrypted by `provider`, which has no
89+
/// route for the model any more, and no other provider can read it.
90+
/// Refused before anything is sent. Maps to 409: the request is well
91+
/// formed, but the conversation cannot go on here.
92+
#[error(
93+
"This conversation's history is encrypted by provider `{provider}`, which can no \
94+
longer serve model `{model}`, and no other provider can read it. Start a new \
95+
conversation."
96+
)]
97+
SealedHistoryUnavailable { provider: String, model: String },
7298
}
7399

74100
impl GatewayError {
@@ -90,6 +116,8 @@ impl GatewayError {
90116
GatewayError::UpstreamRateLimited { .. } | GatewayError::LocalRateLimited { .. } => 429,
91117
GatewayError::UpstreamAuthError { .. } => 401,
92118
GatewayError::PolicyBlocked(_) => 403,
119+
GatewayError::SealedHistoryRefused { status, .. } => i64::from(*status),
120+
GatewayError::SealedHistoryUnavailable { .. } => 409,
93121
}
94122
}
95123

@@ -108,6 +136,8 @@ impl GatewayError {
108136
GatewayError::LocalRateLimited { .. } => "LocalRateLimited",
109137
GatewayError::UpstreamAuthError { .. } => "UpstreamAuthError",
110138
GatewayError::PolicyBlocked(_) => "PolicyBlocked",
139+
GatewayError::SealedHistoryRefused { .. } => "SealedHistoryRefused",
140+
GatewayError::SealedHistoryUnavailable { .. } => "SealedHistoryUnavailable",
111141
}
112142
}
113143

0 commit comments

Comments
 (0)