Skip to content

Commit cf89b17

Browse files
ArthurHeymanseca
andcommitted
feat: support Google Gemini thought signatures
Implements support for capturing and relaying 'thought signatures' required by Google Gemini 3 Pro for function calling. See https://ai.google.dev/gemini-api/docs/thought-signatures "Thought signatures are encrypted representations of the model's internal thought process and are used to preserve reasoning context across multi-turn interactions." This change does the following: - Persist thought signatures in tool call state and pass them back via `extra_content`. - Add `skipThoughtSignatureValidator` config to bypass validation for custom tool calls. - Register `gemini-3-pro-preview` model. 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca <[email protected]>
1 parent eb122c0 commit cf89b17

File tree

8 files changed

+127
-23
lines changed

8 files changed

+127
-23
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Support Google Gemini thought signatures.
6+
- Support `gemini-3-pro-preview` model.
57
- Support `~` in dynamic string parser.
68
- Support removing nullable values from LLM request body if the value in extraPayload is null. #232
79

docs/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,7 @@ To configure, add your OTLP collector config via `:otlp` map following [otlp aut
582582
completionUrlRelativePath?: string;
583583
thinkTagStart?: string;
584584
thinkTagEnd?: string;
585+
skipThoughtSignatureValidator?: boolean;
585586
models: {[key: string]: {
586587
modelName?: string;
587588
extraPayload?: {[key: string]: any}

src/eca/config.clj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@
8282
:key "${env:GOOGLE_API_KEY}"
8383
:requiresAuth? true
8484
:models {"gemini-2.0-flash" {}
85-
"gemini-2.5-pro" {}}}
85+
"gemini-2.5-pro" {}
86+
"gemini-3-pro-preview" {}}}
8687
"ollama" {:url "${env:OLLAMA_API_URL:http://localhost:11434}"}}
8788
:defaultBehavior "agent"
8889
:behavior {"agent" {:systemPrompt "${classpath:prompts/agent_behavior.md}"

src/eca/features/chat.clj

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,7 @@
560560
:server (:server event-data)
561561
:arguments (:arguments event-data)
562562
:origin (:origin event-data)
563+
:thought-signature (:thought-signature event-data)
563564
:decision-reason {:code :none
564565
:text "No reason"})
565566

@@ -1124,7 +1125,7 @@
11241125
:finish (do (add-to-history! {:role "assistant"
11251126
:content [{:type :text :text @received-msgs*}]})
11261127
(finish-chat-prompt! :idle chat-ctx))))
1127-
:on-prepare-tool-call (fn [{:keys [id full-name arguments-text]}]
1128+
:on-prepare-tool-call (fn [{:keys [id full-name arguments-text thought-signature]}]
11281129
(assert-chat-not-stopped! chat-ctx)
11291130
(let [tool (tool-by-full-name full-name all-tools)]
11301131
(transition-tool-call! db* chat-ctx id :tool-prepare
@@ -1133,6 +1134,7 @@
11331134
:full-name full-name
11341135
:origin (:origin tool)
11351136
:arguments-text arguments-text
1137+
:thought-signature thought-signature
11361138
:summary (f.tools/tool-call-summary all-tools full-name nil config)})))
11371139
:on-tools-called (on-tools-called! chat-ctx received-msgs* add-to-history!)
11381140
:on-reason (fn [{:keys [status id text external-id]}]

src/eca/llm_api.clj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@
160160
:tools tools
161161
:think-tag-start "<thought>"
162162
:think-tag-end "</thought>"
163+
:skip-thought-signature-validator? (get-in provider-config [:skipThoughtSignatureValidator])
163164
:extra-payload (merge {:parallel_tool_calls false}
164165
(when reason?
165166
{:extra_body {:google {:thinking_config {:include_thoughts true}}}})

src/eca/llm_providers/openai_chat.clj

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,13 @@
7070
(let [tools-to-call (->> (:choices body)
7171
(mapcat (comp :tool_calls :message))
7272
(map (fn [tool-call]
73-
{:id (:id tool-call)
74-
:full-name (:name (:function tool-call))
75-
:arguments (json/parse-string (:arguments (:function tool-call)))})))]
73+
(cond-> {:id (:id tool-call)
74+
:full-name (:name (:function tool-call))
75+
:arguments (json/parse-string (:arguments (:function tool-call)))}
76+
;; Preserve Google Gemini thought signatures
77+
(get-in tool-call [:extra_content :google :thought_signature])
78+
(assoc :thought-signature
79+
(get-in tool-call [:extra_content :google :thought_signature]))))))]
7680
{:usage (parse-usage (:usage body))
7781
:reason-id (str (random-uuid))
7882
:tools-to-call tools-to-call
@@ -129,13 +133,22 @@
129133

130134
(defn ^:private transform-message
131135
"Transform a single ECA message to OpenAI format. Returns nil for unsupported roles."
132-
[{:keys [role content] :as _msg} supports-image? think-tag-start think-tag-end]
136+
[{:keys [role content] :as _msg} supports-image? think-tag-start think-tag-end skip-thought-signature-validator?]
133137
(case role
134138
"tool_call" {:type :tool-call ; Special marker for accumulation
135-
:data {:id (:id content)
136-
:type "function"
137-
:function {:name (:full-name content)
138-
:arguments (json/generate-string (:arguments content))}}}
139+
:data (cond-> {:id (:id content)
140+
:type "function"
141+
:function {:name (:full-name content)
142+
:arguments (json/generate-string (:arguments content))}}
143+
;; Preserve Google Gemini thought signatures if present
144+
(:thought-signature content)
145+
(assoc-in [:extra_content :google :thought_signature]
146+
(:thought-signature content))
147+
;; Use bypass signature when thought signature is missing and bypass is enabled
148+
(and skip-thought-signature-validator?
149+
(not (:thought-signature content)))
150+
(assoc-in [:extra_content :google :thought_signature]
151+
"skip_thought_signature_validator"))}
139152
"tool_call_output" {:role "tool"
140153
:tool_call_id (:id content)
141154
:content (llm-util/stringfy-tool-result content)}
@@ -196,9 +209,9 @@
196209
'assistant' role message, not as separate messages. This function ensures compliance
197210
with that requirement by accumulating tool calls and flushing them into assistant
198211
messages when a non-tool_call message is encountered."
199-
[messages supports-image? think-tag-start think-tag-end]
212+
[messages supports-image? think-tag-start think-tag-end skip-thought-signature-validator?]
200213
(->> messages
201-
(map #(transform-message % supports-image? think-tag-start think-tag-end))
214+
(map #(transform-message % supports-image? think-tag-start think-tag-end skip-thought-signature-validator?))
202215
(remove nil?)
203216
accumulate-tool-calls
204217
(filter valid-message?)))
@@ -295,15 +308,15 @@
295308
Compatible with OpenRouter and other OpenAI-compatible providers."
296309
[{:keys [model user-messages instructions temperature api-key api-url url-relative-path
297310
past-messages tools extra-payload extra-headers supports-image?
298-
think-tag-start think-tag-end http-client]}
311+
think-tag-start think-tag-end skip-thought-signature-validator? http-client]}
299312
{:keys [on-message-received on-error on-prepare-tool-call on-tools-called on-reason on-usage-updated] :as callbacks}]
300313
(let [think-tag-start (or think-tag-start "<think>")
301314
think-tag-end (or think-tag-end "</think>")
302315
stream? (boolean callbacks)
303316
messages (vec (concat
304317
(when instructions [{:role "system" :content instructions}])
305-
(normalize-messages past-messages supports-image? think-tag-start think-tag-end)
306-
(normalize-messages user-messages supports-image? think-tag-start think-tag-end)))
318+
(normalize-messages past-messages supports-image? think-tag-start think-tag-end skip-thought-signature-validator?)
319+
(normalize-messages user-messages supports-image? think-tag-start think-tag-end skip-thought-signature-validator?)))
307320

308321
body (deep-merge
309322
(assoc-some
@@ -333,7 +346,7 @@
333346
(when-let [{:keys [new-messages]} (on-tools-called tools-to-call)]
334347
(let [new-messages-list (vec (concat
335348
(when instructions [{:role "system" :content instructions}])
336-
(normalize-messages new-messages supports-image? think-tag-start think-tag-end)))
349+
(normalize-messages new-messages supports-image? think-tag-start think-tag-end skip-thought-signature-validator?)))
337350
new-rid (llm-util/gen-rid)]
338351
(reset! tool-calls* {})
339352
(base-chat-request!
@@ -406,8 +419,10 @@
406419
(on-message-received {:type :text :text buf}))
407420
(reset! content-buffer* "")))
408421
(doseq [tool-call (:tool_calls delta)]
409-
(let [{:keys [index id function]} tool-call
422+
(let [{:keys [index id function extra_content]} tool-call
410423
{name :name args :arguments} function
424+
;; Extract Google Gemini thought signature if present
425+
thought-signature (get-in extra_content [:google :thought_signature])
411426
;; Use RID as key to avoid collisions between API requests
412427
tool-key (str rid "-" index)
413428
;; Create globally unique tool call ID for client
@@ -421,7 +436,9 @@
421436
(cond-> (or existing {:index index})
422437
unique-id (assoc :id unique-id)
423438
name (assoc :full-name name)
424-
args (update :arguments-text (fnil str "") args))))
439+
args (update :arguments-text (fnil str "") args)
440+
;; Store thought signature for Google Gemini
441+
thought-signature (assoc :thought-signature thought-signature))))
425442
(when-let [updated-tool-call (get @tool-calls* tool-key)]
426443
(when (and (:id updated-tool-call)
427444
(:full-name updated-tool-call)

test/eca/features/chat_tool_call_state_test.clj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
:server "eca"
3636
:arguments tool-arguments
3737
:origin tool-origin
38+
:thought-signature nil
3839
:decision-reason {:code :none
3940
:text "No reason"}}
4041
(#'f.chat/get-tool-call-state @db* chat-id tool-call-id))

test/eca/llm_providers/openai_chat_test.clj

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
{:role "assistant" :content "I found 2 files"}]
3535
true
3636
thinking-start-tag
37-
thinking-end-tag))))
37+
thinking-end-tag
38+
false))))
3839

3940
(testing "Skips unsupported message types"
4041
(is (match?
@@ -48,7 +49,8 @@
4849
{:role "assistant" :content "Hi"}]
4950
true
5051
thinking-start-tag
51-
thinking-end-tag))))))
52+
thinking-end-tag
53+
false))))))
5254

5355
(deftest extract-content-test
5456
(testing "String input"
@@ -103,7 +105,8 @@
103105
:arguments {:location "NYC"}}}
104106
true
105107
thinking-start-tag
106-
thinking-end-tag))))
108+
thinking-end-tag
109+
false))))
107110

108111
(testing "Tool call output transformation"
109112
(is (match?
@@ -116,15 +119,17 @@
116119
:output {:contents [{:type :text :text "Sunny, 75°F"}]}}}
117120
true
118121
thinking-start-tag
119-
thinking-end-tag))))
122+
thinking-end-tag
123+
false))))
120124

121125
(testing "Unsupported role returns nil"
122126
(is (nil?
123127
(#'llm-providers.openai-chat/transform-message
124128
{:role "unsupported" :content "test"}
125129
true
126130
thinking-start-tag
127-
thinking-end-tag)))))
131+
thinking-end-tag
132+
false)))))
128133

129134
(deftest accumulate-tool-calls-test
130135
(testing "Multiple sequential tool calls get grouped"
@@ -166,6 +171,80 @@
166171
(is (#'llm-providers.openai-chat/valid-message?
167172
{:role "user" :content "Hello world"}))))
168173

174+
(deftest thought-signature-test
175+
(testing "Tool call with thought signature is preserved"
176+
(is (match?
177+
{:type :tool-call
178+
:data {:id "call-123"
179+
:type "function"
180+
:function {:name "eca__get_weather"
181+
:arguments "{\"location\":\"Paris\"}"}
182+
:extra_content {:google {:thought_signature "signature-abc-123"}}}}
183+
(#'llm-providers.openai-chat/transform-message
184+
{:role "tool_call"
185+
:content {:id "call-123"
186+
:full-name "eca__get_weather"
187+
:arguments {:location "Paris"}
188+
:thought-signature "signature-abc-123"}}
189+
true
190+
thinking-start-tag
191+
thinking-end-tag
192+
false))))
193+
194+
(testing "Tool call without thought signature when bypass is disabled"
195+
(is (match?
196+
{:type :tool-call
197+
:data {:id "call-456"
198+
:type "function"
199+
:function {:name "eca__get_weather"
200+
:arguments "{\"location\":\"London\"}"}}}
201+
(#'llm-providers.openai-chat/transform-message
202+
{:role "tool_call"
203+
:content {:id "call-456"
204+
:full-name "eca__get_weather"
205+
:arguments {:location "London"}}}
206+
true
207+
thinking-start-tag
208+
thinking-end-tag
209+
false))))
210+
211+
(testing "Tool call without thought signature gets bypass when enabled"
212+
(is (match?
213+
{:type :tool-call
214+
:data {:id "call-789"
215+
:type "function"
216+
:function {:name "eca__get_weather"
217+
:arguments "{\"location\":\"Tokyo\"}"}
218+
:extra_content {:google {:thought_signature "skip_thought_signature_validator"}}}}
219+
(#'llm-providers.openai-chat/transform-message
220+
{:role "tool_call"
221+
:content {:id "call-789"
222+
:full-name "eca__get_weather"
223+
:arguments {:location "Tokyo"}}}
224+
true
225+
thinking-start-tag
226+
thinking-end-tag
227+
true))))
228+
229+
(testing "Tool call with thought signature is not overridden by bypass"
230+
(is (match?
231+
{:type :tool-call
232+
:data {:id "call-999"
233+
:type "function"
234+
:function {:name "eca__get_weather"
235+
:arguments "{\"location\":\"Berlin\"}"}
236+
:extra_content {:google {:thought_signature "original-signature"}}}}
237+
(#'llm-providers.openai-chat/transform-message
238+
{:role "tool_call"
239+
:content {:id "call-999"
240+
:full-name "eca__get_weather"
241+
:arguments {:location "Berlin"}
242+
:thought-signature "original-signature"}}
243+
true
244+
thinking-start-tag
245+
thinking-end-tag
246+
true)))))
247+
169248
(defn process-text-think-aware [texts]
170249
(let [content-buffer* (atom "")
171250
reasoning-type* (atom nil)

0 commit comments

Comments
 (0)