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
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,40 @@ target.

## [Unreleased]

### Fixed

- **A conversation whose history a provider encrypted goes back to that
provider.** OpenAI's Responses API with `store: false` (Codex always
sends it) hands the client encrypted state that comes back every turn:
reasoning, remote compactions, Codex's messages between agents,
encrypted tool output. Only the account that encrypted it can read it,
so a conversation that moved to another provider — after five idle
minutes, a tripped breaker or a failover — was refused on every turn
from then on with `invalid_encrypted_content`. The gateway now
remembers, for 30 days and whatever the affinity settings say, the
provider that last answered each conversation (the authenticated user
and the request's `x-session-id`, or else the session header the client
sends by itself, such as Codex's `session_id`; over HTTP and over the
WebSocket). The log row's `session_id` still comes from `x-session-id`
only. A
Responses request carrying encrypted content that cannot be left out
goes only to that provider: no strategy pick, no failover, and its open
breaker does not stop it. When that provider has no route for the model
any more, the request is refused up front with 409, naming the provider
and saying to start a new conversation. A request carrying only
encrypted reasoning goes to that provider first and still fails over.
Requests that name no conversation are routed as before. The request's log
row records `"pinned_by": "encrypted_history"` when the provider was
picked this way.
- **A provider that cannot read a conversation's encrypted history says
so.** Its refusal, as a 4xx answer or as the error event of a stream
that opened with 200, now reaches the caller and the log as
`SealedHistoryRefused`: the provider cannot read this conversation's
encrypted history because another provider encrypted it; route the
conversation back to that provider or start a new conversation. The
upstream's own words follow. It no longer counts against the route's
breaker.

## [3.9.0] — 2026-10-20

The Add to ThinkWatch Lite link from a rotated key now carries the key's
Expand Down
18 changes: 9 additions & 9 deletions Cargo.lock

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

14 changes: 10 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,16 @@ opt-level = 3
# never re-exported through a local shim. And the reverse: something only
# this side uses (the at-rest crypto, IMDSv2 credentials, the gateway error)
# lives here, not in core.
tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.77.0" }
tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.77.0" }
tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.77.0" }
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.77.0" }
#
# TEMPORARY: on core main at the merge of core PR #368 (`tw_dialect::sealed`)
# until core tags it — replace with the next core tag before this merges.
# Set on the dependencies themselves rather than in a `[patch]` table: core's
# CI appends its own `[patch]` table for this URL, and a second one would not
# parse.
tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "366833934f58c490fa5eceb8e7b6d3a3d979c719" }
tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "366833934f58c490fa5eceb8e7b6d3a3d979c719" }
tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "366833934f58c490fa5eceb8e7b6d3a3d979c719" }
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "366833934f58c490fa5eceb8e7b6d3a3d979c719" }

# Web framework
axum = { version = "0.8", features = ["macros", "ws"] }
Expand Down
30 changes: 30 additions & 0 deletions crates/gateway/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,32 @@ pub enum GatewayError {
/// pass it on. Maps to 403.
#[error("Blocked by policy: {0}")]
PolicyBlocked(String),
/// The upstream refused the conversation's encrypted history (OpenAI
/// Responses `encrypted_content`, see `tw_dialect::sealed`): another
/// provider's account encrypted it, and only that one can read it.
/// `status` is the upstream's (as an answer, or what its error in a
/// stream would have been), `said` its own words, truncated. Not the
/// upstream failing: no failover, nothing against its breaker.
#[error(
"Provider `{provider}` cannot read this conversation's encrypted history: another \
provider encrypted it, and only that one can read it. Route the conversation back to \
that provider, or start a new conversation. The provider said: {said}"
)]
SealedHistoryRefused {
status: u16,
provider: String,
said: String,
},
/// The conversation's history is encrypted by `provider`, which has no
/// route for the model any more, and no other provider can read it.
/// Refused before anything is sent. Maps to 409: the request is well
/// formed, but the conversation cannot go on here.
#[error(
"This conversation's history is encrypted by provider `{provider}`, which can no \
longer serve model `{model}`, and no other provider can read it. Start a new \
conversation."
)]
SealedHistoryUnavailable { provider: String, model: String },
}

impl GatewayError {
Expand All @@ -90,6 +116,8 @@ impl GatewayError {
GatewayError::UpstreamRateLimited { .. } | GatewayError::LocalRateLimited { .. } => 429,
GatewayError::UpstreamAuthError { .. } => 401,
GatewayError::PolicyBlocked(_) => 403,
GatewayError::SealedHistoryRefused { status, .. } => i64::from(*status),
GatewayError::SealedHistoryUnavailable { .. } => 409,
}
}

Expand All @@ -108,6 +136,8 @@ impl GatewayError {
GatewayError::LocalRateLimited { .. } => "LocalRateLimited",
GatewayError::UpstreamAuthError { .. } => "UpstreamAuthError",
GatewayError::PolicyBlocked(_) => "PolicyBlocked",
GatewayError::SealedHistoryRefused { .. } => "SealedHistoryRefused",
GatewayError::SealedHistoryUnavailable { .. } => "SealedHistoryUnavailable",
}
}

Expand Down
Loading
Loading