Skip to content

Commit be35f1e

Browse files
nightt5879Hmbown
andcommitted
fix(client): explain missing Gemini thought signatures
A compatible gateway can reject unsigned Gemini tool-call history with a raw missing-thought_signature 400 even though it is allowed through the official-route preflight guard. Explain the route and new-session recovery at the shared HTTP error boundary so streaming and non-streaming callers both receive useful guidance while gateways managing signatures still work. Keep provider details bounded, preserve quota/HTML handling, and avoid duplicating guidance when the error is formatted again. Add mocked transport and error-boundary regressions and record the report in the changelog. Reported by @Hmbown in #6048. Verified with 409 related tests passing. Removing the production change fails three recovery regressions, including the mocked HTTP 400 path. Co-authored-by: Hunter Bown <101357273+Hmbown@users.noreply.github.com> Signed-off-by: Nightt <87569709+nightt5879@users.noreply.github.com>
1 parent f2f5d46 commit be35f1e

6 files changed

Lines changed: 208 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- Gemini tool-call replays rejected by compatible gateways for a missing
13+
`thought_signature` now explain how to recover: use the built-in `google`
14+
provider or a gateway that preserves signatures, then start a new session.
15+
Gateways that manage signatures themselves continue to work. Reported by
16+
@Hmbown (#6048).
17+
1018
## [0.9.13] - 2026-09-10
1119

1220
Codewhale v0.9.13 source candidate addresses integrity issues in 0.9.12:

crates/tui/src/client/chat.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7211,6 +7211,87 @@ mod google_thought_signature_tests {
72117211
request_from(signed_history(signature, true))
72127212
}
72137213

7214+
#[tokio::test]
7215+
async fn gateway_thought_signature_rejection_explains_recovery_after_transport() {
7216+
use crate::llm_client::LlmClient;
7217+
use wiremock::matchers::{method, path};
7218+
use wiremock::{Mock, MockServer, ResponseTemplate};
7219+
7220+
let _ = rustls::crypto::ring::default_provider().install_default();
7221+
// An unsigned replay must reach the gateway: it may manage Google's
7222+
// signatures itself. Only an actual rejection warrants recovery advice.
7223+
for streaming in [false, true] {
7224+
for status in [200, 400] {
7225+
let server = MockServer::start().await;
7226+
let response = if status == 400 {
7227+
ResponseTemplate::new(status).set_body_json(json!({
7228+
"error": {
7229+
"code": 400,
7230+
"message": "Function call is missing a thought_signature in functionCall parts."
7231+
}
7232+
}))
7233+
} else if streaming {
7234+
ResponseTemplate::new(status)
7235+
.insert_header("content-type", "text/event-stream")
7236+
.set_body_string("data: [DONE]\n\n")
7237+
} else {
7238+
ResponseTemplate::new(status).set_body_json(json!({
7239+
"id": "gateway-replay",
7240+
"model": "gemini-3.1-pro-preview",
7241+
"choices": [{
7242+
"message": {"role": "assistant", "content": "Done."},
7243+
"finish_reason": "stop"
7244+
}]
7245+
}))
7246+
};
7247+
Mock::given(method("POST"))
7248+
.and(path("/v1/chat/completions"))
7249+
.respond_with(response)
7250+
.expect(1)
7251+
.mount(&server)
7252+
.await;
7253+
7254+
let mut client = DeepSeekClient::new(&crate::config::Config {
7255+
provider: Some("openai".to_string()),
7256+
providers: Some(crate::config::ProvidersConfig {
7257+
openai: crate::config::ProviderConfig {
7258+
api_key: Some("gateway-test-key".to_string()),
7259+
base_url: Some(format!("{}/v1", server.uri())),
7260+
model: Some("gemini-3.1-pro-preview".to_string()),
7261+
..crate::config::ProviderConfig::default()
7262+
},
7263+
..crate::config::ProvidersConfig::default()
7264+
}),
7265+
..crate::config::Config::default()
7266+
})
7267+
.expect("gateway client");
7268+
client.isolated_request_state = true;
7269+
let request = google_request_with_signed_tool(None);
7270+
let result = if streaming {
7271+
client.create_message_stream(request).await.map(|_| ())
7272+
} else {
7273+
client
7274+
.create_message_without_response_cache(request)
7275+
.await
7276+
.map(|_| ())
7277+
};
7278+
if status == 400 {
7279+
let error = result.expect_err("gateway rejects unsigned replay");
7280+
let message = error.to_string();
7281+
assert!(message.contains("built-in `google` provider"), "{message}");
7282+
assert!(message.contains("start a new session"), "{message}");
7283+
assert!(matches!(
7284+
error.downcast_ref::<crate::llm_client::LlmError>(),
7285+
Some(crate::llm_client::LlmError::InvalidRequest { status: 400, .. })
7286+
));
7287+
} else {
7288+
result.expect("gateway-managed signatures must still work");
7289+
}
7290+
server.verify().await;
7291+
}
7292+
}
7293+
}
7294+
72147295
#[test]
72157296
fn google_route_round_trips_thought_signatures_on_replayed_tool_calls() {
72167297
let request = google_request_with_signed_tool(Some("SIG-abc123"));

crates/tui/src/llm_client/mod.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -648,7 +648,31 @@ pub(crate) fn sanitize_http_error_body(
648648
status: u16,
649649
body: &str,
650650
) -> String {
651-
if let Some(message) = extract_json_error_message(body) {
651+
let json_message = extract_json_error_message(body);
652+
let message = json_message.as_deref().unwrap_or(body);
653+
// Gate on Google's actual rejection, not the selected provider or model:
654+
// compatible gateways may manage signatures themselves (#6048). This
655+
// shared boundary covers both streaming and non-streaming HTTP failures.
656+
const SIGNATURE_HINT: &str = "Gemini rejected tool-call replay because a thought signature is missing. \
657+
Use the built-in `google` provider with its default endpoint, or a gateway that preserves \
658+
Google thought signatures, then start a new session before using tools. \
659+
Changing reasoning settings will not restore missing signatures.";
660+
if status == 400
661+
&& !is_probably_html(message)
662+
&& explicit_quota_code(body).is_none()
663+
&& !message.contains(SIGNATURE_HINT)
664+
{
665+
let lower = collapse_whitespace(message).to_ascii_lowercase();
666+
if lower.contains("missing a thought_signature")
667+
|| lower.contains("missing thought_signature")
668+
|| lower.contains("thought_signature is missing")
669+
{
670+
let detail = truncate_for_error(&collapse_whitespace(message), 900);
671+
return format!("{SIGNATURE_HINT} Provider error: {detail}");
672+
}
673+
}
674+
675+
if let Some(message) = json_message {
652676
let message = truncate_for_error(&collapse_whitespace(&message), 2_000);
653677
if let Some(code) = explicit_quota_code(body) {
654678
return format!("{message} (provider error code: {code})");

crates/tui/src/llm_client/tests.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,89 @@
11
use super::*;
22

3+
#[test]
4+
fn missing_google_thought_signature_errors_explain_recovery() {
5+
for detail in [
6+
"Function call is missing a thought_signature in functionCall parts.",
7+
"Function call is missing thought_signature.",
8+
"The thought_signature is missing from the function call.",
9+
] {
10+
for body in [
11+
detail.to_string(),
12+
serde_json::json!({"error": {"message": detail, "code": 400}}).to_string(),
13+
serde_json::json!({"error": "Bad Request", "message": detail}).to_string(),
14+
] {
15+
let message = sanitize_http_error_body(Some("Custom"), 400, &body);
16+
assert!(message.contains("built-in `google` provider"), "{message}");
17+
assert!(message.contains("start a new session"), "{message}");
18+
assert!(message.contains(detail), "provider detail must survive");
19+
assert_eq!(sanitize_http_error_body(None, 400, &message), message);
20+
let error = LlmError::from_http_response(400, &message);
21+
assert!(matches!(
22+
error,
23+
LlmError::InvalidRequest { status: 400, .. }
24+
));
25+
assert!(!error.is_retryable());
26+
}
27+
}
28+
}
29+
30+
#[test]
31+
fn google_thought_signature_hint_requires_a_missing_signature_400() {
32+
for (status, detail) in [
33+
(400, "Invalid model name"),
34+
(400, "Invalid thought_signature in functionCall parts"),
35+
(400, "Unsupported parameter: thought_signature"),
36+
(
37+
401,
38+
"Function call is missing a thought_signature in functionCall parts.",
39+
),
40+
(
41+
429,
42+
"Function call is missing a thought_signature in functionCall parts.",
43+
),
44+
(
45+
500,
46+
"Function call is missing a thought_signature in functionCall parts.",
47+
),
48+
] {
49+
let body = serde_json::json!({"error": {"message": detail}}).to_string();
50+
assert_eq!(
51+
sanitize_http_error_body(Some("Custom"), status, &body),
52+
detail
53+
);
54+
}
55+
}
56+
57+
#[test]
58+
fn google_thought_signature_hint_keeps_large_provider_errors_bounded() {
59+
let body = format!(
60+
"Function call is missing a thought_signature in functionCall parts. {}",
61+
"界".repeat(3_000)
62+
);
63+
let message = sanitize_http_error_body(None, 400, &body);
64+
assert!(message.contains("start a new session"));
65+
assert!(message.chars().count() < 2_000);
66+
assert_eq!(sanitize_http_error_body(None, 400, &message), message);
67+
}
68+
69+
#[test]
70+
fn google_thought_signature_hint_preserves_quota_and_html_handling() {
71+
let detail = "Function call is missing a thought_signature in functionCall parts.";
72+
let body = serde_json::json!({
73+
"error": {"message": detail, "code": "insufficient_quota"}
74+
})
75+
.to_string();
76+
let message = sanitize_http_error_body(None, 400, &body);
77+
assert!(matches!(
78+
LlmError::from_http_response(400, &message),
79+
LlmError::QuotaExhausted(_)
80+
));
81+
let html = format!("<!doctype html><html><body>{detail}</body></html>");
82+
let message = sanitize_http_error_body(None, 400, &html);
83+
assert!(message.contains("HTML error page"));
84+
assert!(!message.contains("<html>"));
85+
}
86+
387
#[test]
488
fn retryability_distinguishes_transient_failures_from_durable_failures() {
589
for error in [

docs/CONTRIBUTORS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ notes, and relevant issue/PR comments.
4343

4444
**Reports and reproductions**
4545

46+
- **[Hmbown](https://github.com/Hmbown)** — identified missing recovery guidance when a compatible gateway rejects Gemini tool-call replay without thought signatures ([#6048](https://github.com/Hmbown/Codewhale/issues/6048)).
4647
- **[7jrxt42BxFZo4iAnN4CX](https://github.com/7jrxt42BxFZo4iAnN4CX)** — proposed global usage and tool diagnostics and independent goal verification ([#6011](https://github.com/Hmbown/Codewhale/issues/6011), [#6013](https://github.com/Hmbown/Codewhale/issues/6013)); these broader requests remain open.
4748
- **[nsfoxer](https://github.com/nsfoxer)** — reported the multiline-paste regression and incomplete provider model lists ([#5981](https://github.com/Hmbown/Codewhale/issues/5981), [#6009](https://github.com/Hmbown/Codewhale/issues/6009)).
4849
- **[Nefelibata1024](https://github.com/Nefelibata1024)** — confirmed the multiline-paste regression's impact ([#5981](https://github.com/Hmbown/Codewhale/issues/5981)).

web/lib/changelog.generated.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,15 @@ export const CHANGELOG: ChangelogRelease[] = [
2626
"date": null,
2727
"unreleased": true,
2828
"compareUrl": "https://github.com/Hmbown/CodeWhale/compare/v0.9.12...HEAD",
29-
"sections": []
29+
"sections": [
30+
{
31+
"heading": "Fixed",
32+
"items": [
33+
"Gemini tool-call replays rejected by compatible gateways for a missing thought_signature now explain how to recover: use the built-in google provider or a gateway that preserves signatures, then start a new session. Gateways that manage signatures themselves continue to work. Reported by @Hmbown (#6048)."
34+
],
35+
"itemCount": 1
36+
}
37+
]
3038
},
3139
{
3240
"version": "0.9.13",

0 commit comments

Comments
 (0)