Skip to main content

gproxy_protocol/transform/generate/
signature.rs

1//! Upstream signatures carried by the client in its own protocol's field.
2//!
3//! A Gemini `thoughtSignature` or a Claude thinking `signature` is opaque to
4//! the client and verified by the upstream that issued it, so the gateway
5//! keeps nothing: it hands the native value to the client in the field its
6//! protocol already has for reasoning state (a Claude thinking block's
7//! `signature`, a Responses reasoning item's `encrypted_content`), and reads
8//! it back from there on the next turn. The value is not encrypted or wrapped;
9//! only a short prefix names its source, so a later turn can put it back where
10//! that upstream expects it, or strip it when the conversation has moved to an
11//! upstream that would reject it.
12//!
13//! - `gemini:<signature>` is the signature of a Gemini thought part. The
14//!   carrier's text is the part's text, and the pair replays as one
15//!   `{thought: true, text, thoughtSignature}` part: the only shape a Claude
16//!   model behind Antigravity accepts. An empty signature marks thought text
17//!   the upstream did not sign; it replays as nothing.
18//! - `gemini-next:<signature>` is the signature of the Gemini part that the
19//!   carrier precedes in the client's history (a `functionCall`, or a
20//!   generated image), which has no field of its own for it. The carrier has
21//!   no text.
22//! - `claude:<signature>` is a Claude thinking block's signature, shown to a
23//!   client whose reasoning field is not a Claude one.
24//!
25//! A Claude client's own Claude signature carries no prefix: it is native
26//! there, and a Claude request routed to Anthropic as it is must not change.
27//! Chat clients carry signatures in `reasoning_details`, whose `format` names
28//! the source instead (see `reasoning_details`).
29
30/// A Gemini thought part's signature.
31pub const GEMINI: &str = "gemini:";
32/// A Gemini signature for the part after the carrier.
33pub const GEMINI_NEXT: &str = "gemini-next:";
34/// A Claude thinking block's signature.
35pub const CLAUDE: &str = "claude:";
36
37/// A carried signature, with its source prefix removed.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Carried<'a> {
40    /// A Gemini thought part's signature; empty when the thought was unsigned.
41    GeminiThought(&'a str),
42    /// The signature of the Gemini part the carrier precedes.
43    GeminiNext(&'a str),
44    /// A Claude thinking signature.
45    Claude(&'a str),
46}
47
48impl<'a> Carried<'a> {
49    /// The carried form of `value`, or `None` for a value without a known
50    /// prefix (a native signature, or an upstream's own ciphertext).
51    pub fn parse(value: &'a str) -> Option<Self> {
52        if let Some(signature) = value.strip_prefix(GEMINI_NEXT) {
53            Some(Self::GeminiNext(signature))
54        } else if let Some(signature) = value.strip_prefix(GEMINI) {
55            Some(Self::GeminiThought(signature))
56        } else {
57            value.strip_prefix(CLAUDE).map(Self::Claude)
58        }
59    }
60    /// True when the value came from a Gemini upstream.
61    pub fn is_gemini(self) -> bool {
62        matches!(self, Self::GeminiThought(_) | Self::GeminiNext(_))
63    }
64}
65
66/// True for a value that carries a Gemini signature in any form.
67pub fn is_gemini(value: &str) -> bool {
68    Carried::parse(value).is_some_and(Carried::is_gemini)
69}
70
71/// True for a value with any source prefix gproxy adds.
72pub fn is_carried(value: &str) -> bool {
73    Carried::parse(value).is_some()
74}
75
76/// The carried form of a Gemini thought part's signature; `None` marks an
77/// unsigned thought.
78pub fn gemini_thought(signature: Option<&str>) -> String {
79    format!("{GEMINI}{}", signature.unwrap_or_default())
80}
81
82/// The carried form of the signature of the Gemini part after the carrier.
83pub fn gemini_next(signature: &str) -> String {
84    format!("{GEMINI_NEXT}{signature}")
85}
86
87/// The carried form of a Claude thinking signature.
88pub fn claude(signature: &str) -> String {
89    format!("{CLAUDE}{signature}")
90}
91
92/// A Responses request (or input token count) body without the reasoning
93/// items whose `encrypted_content` carries another upstream's signature;
94/// `None` when it holds none. This is for a request that reaches a Responses
95/// upstream as it is, without conversion: that upstream decrypts its own
96/// ciphertext only, and refuses a Claude or Gemini signature it finds there.
97pub fn without_carried_reasoning(body: &[u8]) -> Option<Vec<u8>> {
98    let needles = [GEMINI, GEMINI_NEXT, CLAUDE].map(str::as_bytes);
99    if !needles
100        .iter()
101        .any(|needle| body.windows(needle.len()).any(|window| window == *needle))
102    {
103        return None;
104    }
105    let mut value: serde_json::Value = serde_json::from_slice(body).ok()?;
106    let items = value.get_mut("input")?.as_array_mut()?;
107    let before = items.len();
108    items.retain(|item| {
109        item.get("type").and_then(serde_json::Value::as_str) != Some("reasoning")
110            || !item
111                .get("encrypted_content")
112                .and_then(serde_json::Value::as_str)
113                .is_some_and(is_carried)
114    });
115    if items.len() == before {
116        return None;
117    }
118    serde_json::to_vec(&value).ok()
119}