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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,28 @@ target.
upstream's 500 (`ProviderHttpError`), as the same error answered with
HTTP 500 is, instead of 502 (`ProviderError`).

### Fixed

- **A refused `none` is sent again with the lowest level, not without an
effort.** When an upstream answered 400 to the `none` that
`models.reasoning_effort: off` writes in OpenAI's formats (models that
take only `minimal`, `low`, `medium` or `high`), the request went out
again without the effort, so the model reasoned at its default level,
usually `medium`, and that was remembered for 7 days. It now goes out
with the lowest level the error names, and without the effort only when
the error names none (tw-dialect `think::none_fix`).
- **`off` reaches an upstream of another format.** For a request converted
for another format, such as an Anthropic caller routed to an OpenAI
Chat or Responses upstream, `off` took the caller's thinking out before
the conversion, so the converted request carried no effort and the
upstream reasoned at its default, while the log row's `"effort"` said
it was off. The setting is now applied to the converted request: OpenAI
Chat and Responses upstreams receive `none`, Gemini 2.5 Flash a thinking
budget of 0, and Anthropic and Bedrock upstreams no thinking, as
before. Gemini models that cannot stop thinking (2.5 Pro, Gemini 3) get
no thinking configuration, as before. The `"effort"` entry of a
converted hop records what that hop sent.

## [3.9.1] — 2026-10-23

A conversation whose history a provider encrypted, such as a Codex
Expand Down
20 changes: 10 additions & 10 deletions Cargo.lock

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

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,10 @@ 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", rev = "14f52f25a434faf1e7336746b57c9ad8d8aebb96" }
tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "14f52f25a434faf1e7336746b57c9ad8d8aebb96" }
tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "14f52f25a434faf1e7336746b57c9ad8d8aebb96" }
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "14f52f25a434faf1e7336746b57c9ad8d8aebb96" }
tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "9d5abe2f980fbd0983d5f2a84b232cb8fb4fea7a" }
tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "9d5abe2f980fbd0983d5f2a84b232cb8fb4fea7a" }
tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "9d5abe2f980fbd0983d5f2a84b232cb8fb4fea7a" }
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "9d5abe2f980fbd0983d5f2a84b232cb8fb4fea7a" }

# Web framework
axum = { version = "0.8", features = ["macros", "ws"] }
Expand Down
63 changes: 51 additions & 12 deletions crates/gateway/src/proxy/effort.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,27 @@
//! on**; a request without it — a title, a summary, a client that never
//! asked — goes out untouched: reasoning is never turned on for a caller.
//! Unset: requests go out as sent. It applies to each hop as it is
//! addressed, to the caller's request in its own format and for the model
//! named upstream (Gemini's budgets are capped per model); a request
//! converted for another format carries the changed effort the way that
//! format writes it. What reasoning is on, and where each format writes the
//! effort, is `tw_dialect::params`, shared with the desktop gateway.
//! addressed, for the model named upstream (Gemini's budgets are capped
//! per model). A level is applied to the caller's request in its own
//! format; a request converted for another format carries the changed
//! effort the way that format writes it. `off` on a converted hop is
//! applied to the conversion instead ([`turn_off_converted`]): taking the
//! caller's reasoning out first would leave the converted request with no
//! effort at all, which OpenAI's models read as their default level. What
//! reasoning is on, and where each format writes the effort, is
//! `tw_dialect::params`, shared with the desktop gateway.
//!
//! # What an upstream refuses
//!
//! Not every model takes every level, and an upstream that does not says
//! so with a 400 (`tw_dialect::think::effort_rejection`; for a `none` it
//! does not take, `none_rejection`). Both are read against the effort the
//! does not take, `none_fix`). Both are read against the effort the
//! hop **wrote** (`tw_dialect::params::written_effort`), whether or not
//! reasoning is on — Claude with thinking off still refuses `xhigh` — and a
//! request that only carries a budget has none to refuse. The request then
//! goes to the **same upstream** once more, with the level it named or,
//! when it named none, without the effort (the model's default). That is
//! goes to the **same upstream** once more, with the level it named (for a
//! refused `none`, the lowest it named) or, when it named none, without the
//! effort (the model's default). That is
//! not a retry of a failure of the moment: it does not wait and does not
//! count against `gateway.retry_wait_secs` (`super::retry`). A second
//! refusal is the answer.
Expand Down Expand Up @@ -106,6 +111,30 @@ impl ReasoningSetting {
}
}

/// `off` on a hop converted for `target`, applied to the caller's decoded
/// request `r` before it is encoded. Whether reasoning was on and is
/// turned off; the caller's reasoning is read as for the same-format hop.
///
/// The reasoning configuration is taken out, except for Gemini, where it is
/// marked off: Gemini 2.5 thinks without one, and the encoder writes a
/// zero budget for the models that can stop (2.5 Flash) and reports the
/// setting dropped for those that cannot. For OpenAI's formats the
/// converted body then needs `none` written in it
/// (`tw_dialect::params::write_reasoning_none`): without an effort their
/// models reason at their default level.
pub(crate) fn turn_off_converted(r: &mut tw_dialect::ir::Request, target: Dialect) -> bool {
if !r.reasoning.as_ref().is_some_and(|re| re.enabled) {
return false;
}
r.reasoning = (target == Dialect::Gemini).then_some(tw_dialect::ir::Reasoning {
enabled: false,
effort: None,
budget: None,
summary: false,
});
true
}

/// What the caller's request asks for reasoning, read once.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct Asked {
Expand Down Expand Up @@ -239,9 +268,7 @@ pub(crate) fn rejection(err: &GatewayError, label: &str, sent: WrittenEffort) ->
.unwrap_or(message);
match sent {
WrittenEffort::Level(e) => tw_dialect::think::effort_rejection(*status, said, e),
WrittenEffort::Off => {
tw_dialect::think::none_rejection(*status, said).then_some(EffortFix::Drop)
}
WrittenEffort::Off => tw_dialect::think::none_fix(*status, said),
}
}

Expand Down Expand Up @@ -589,12 +616,24 @@ mod tests {
rejection(&http(400, claude), "relay", L(Effort::XHigh)),
Some(EffortFix::Use(Effort::High))
);
// `none` not taken: left out.
// `none` not taken, no level named: left out.
let none = r#"{"error":{"message":"Invalid value for reasoning_effort: 'none'"}}"#;
assert_eq!(
rejection(&http(400, none), "relay", WrittenEffort::Off),
Some(EffortFix::Drop)
);
// `none` not taken, levels named: the lowest, not the model's
// default, which would reason more.
let listed = r#"{"error":{"message":"Unsupported value: 'none' is not supported with this model. Supported values are: 'minimal', 'low', 'medium', and 'high'.","param":"reasoning_effort"}}"#;
assert_eq!(
rejection(&http(400, listed), "relay", WrittenEffort::Off),
Some(EffortFix::Use(Effort::Minimal))
);
let listed = r#"{"error":{"message":"Unsupported value: 'none' is not supported with this model. Supported values are: 'low', 'medium', and 'high'.","param":"reasoning.effort"}}"#;
assert_eq!(
rejection(&http(400, listed), "relay", WrittenEffort::Off),
Some(EffortFix::Use(Effort::Low))
);
// Not a 400: an upstream failing is not a refusal.
assert_eq!(rejection(&http(500, said), "relay", L(Effort::XHigh)), None);
// A refusal of something else.
Expand Down
Loading
Loading