gproxy_protocol/transform/generate/claude_gemini/thinking.rs
1//! Gemini thought runs shown to Claude clients, and the reverse on replay.
2//!
3//! A Claude `thinking` block must carry a `signature`, and a Gemini thought
4//! carries a `thoughtSignature` that only a Gemini upstream can verify. A
5//! signed run becomes one block whose signature is the native value behind the
6//! `gemini:` prefix (see `signature`); a later request to a Gemini upstream
7//! turns it back into one thought part with the run's text and signature, and
8//! every other path recognises the prefix and drops the block, so a Gemini
9//! signature never reaches Anthropic or any other upstream.
10//!
11//! A run is a sequence of text-only thought parts closed by the part that
12//! carries the signature (Code Assist streams the thought text first, then an
13//! empty thought part holding only the signature). A run that ends without a
14//! signature is an unsigned summary: it is shown with the bare prefix and
15//! dropped on replay, since Gemini reads nothing from unsigned thought text.
16//! A signature on a function call (Gemini flash models) has no Claude field of
17//! its own, so it travels on an empty thinking block right before the
18//! `tool_use` it belongs to, behind the `gemini-next:` prefix.
19
20use crate::{transform::generate::signature, wire::gemini as g};
21
22/// A thought part that belongs to a run: thought text, or the empty thought
23/// part carrying the run's signature. Thought media keeps its own mapping.
24pub(crate) fn is_run_part(part: &g::Part) -> bool {
25 part.thought == Some(true)
26 && (part.text.is_some() || part.thought_signature.is_some())
27 && part.function_call.is_none()
28 && part.function_response.is_none()
29 && part.inline_data.is_none()
30 && part.file_data.is_none()
31 && part.executable_code.is_none()
32 && part.code_execution_result.is_none()
33}
34
35/// An empty visible text part with nothing else: Code Assist opens and closes
36/// streams with these. It adds no text, opens no block and does not end a
37/// thought run.
38pub(crate) fn is_empty_text(part: &g::Part) -> bool {
39 part.thought != Some(true)
40 && part.text.as_deref() == Some("")
41 && part.thought_signature.is_none()
42 && part.function_call.is_none()
43 && part.function_response.is_none()
44 && part.inline_data.is_none()
45 && part.file_data.is_none()
46 && part.executable_code.is_none()
47 && part.code_execution_result.is_none()
48}
49
50/// The signature a `tool_use` built from `part` needs in front of it: set only
51/// for a signed function call, which is the only signed non-thought part a
52/// Claude client can replay.
53pub(crate) fn call_signature(part: &g::Part) -> Option<String> {
54 part.function_call
55 .as_ref()
56 .and(part.thought_signature.as_deref())
57 .map(signature::gemini_next)
58}
59
60/// A Claude Messages (or count tokens) body with every Gemini-signed
61/// `thinking` block removed, and any message the removal empties; `None`
62/// when the body holds none. This is for a request that reaches an Anthropic
63/// upstream as it is, without conversion: Anthropic refuses a signature it
64/// did not issue, which is the case after the conversation moved over from a
65/// Gemini upstream.
66pub fn without_gemini_thinking(body: &[u8]) -> Option<Vec<u8>> {
67 let needles = [signature::GEMINI, signature::GEMINI_NEXT].map(str::as_bytes);
68 if !needles
69 .iter()
70 .any(|needle| body.windows(needle.len()).any(|window| window == *needle))
71 {
72 return None;
73 }
74 let mut value: serde_json::Value = serde_json::from_slice(body).ok()?;
75 let messages = value.get_mut("messages")?.as_array_mut()?;
76 let mut changed = false;
77 for message in messages.iter_mut() {
78 let Some(content) = message
79 .get_mut("content")
80 .and_then(serde_json::Value::as_array_mut)
81 else {
82 continue;
83 };
84 let before = content.len();
85 content.retain(|block| {
86 block.get("type").and_then(serde_json::Value::as_str) != Some("thinking")
87 || !block
88 .get("signature")
89 .and_then(serde_json::Value::as_str)
90 .is_some_and(signature::is_gemini)
91 });
92 changed |= content.len() != before;
93 }
94 if !changed {
95 return None;
96 }
97 messages.retain(|message| {
98 message
99 .get("content")
100 .and_then(serde_json::Value::as_array)
101 .is_none_or(|content| !content.is_empty())
102 });
103 serde_json::to_vec(&value).ok()
104}