Skip to content

Commit dbdf726

Browse files
authored
Merge pull request #264 from editor-code-assistant/fix-gemini-openai-chat-tool-calling-2
Fix gemini OpenAI chat tool calling 2
2 parents 9a1990a + f5cc5de commit dbdf726

File tree

3 files changed

+34
-28
lines changed

3 files changed

+34
-28
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+
- Fix Gemini (OpenAI compatible). #247
6+
57
## 0.91.2
68

79
- Fix `eca_shell_command` to include stderr output even when exit 0.

src/eca/llm_providers/openai_chat.clj

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,12 @@
2525

2626
(defn ^:private extract-content
2727
"Extract text content from various message content formats.
28-
Handles: strings (legacy eca), nested arrays from chat.clj, and fallback."
28+
Returns a string for text-only content, and an array only when images are present (multimodal)."
2929
[content supports-image?]
3030
(cond
3131
;; Legacy/fallback: handles system messages, error strings, or unexpected simple text content
3232
(string? content)
33-
[{:type "text"
34-
:text (string/trim content)}]
33+
(string/trim content)
3534

3635
(and (sequential? content)
3736
(every? #(= "text" (name (:type %))) content))
@@ -166,8 +165,7 @@
166165
:reasoning_content (:text content)}
167166
;; Fallback: wrap in thinking tags for models that use text-based reasoning
168167
{:role "assistant"
169-
:content [{:type "text"
170-
:text (str think-tag-start (:text content) think-tag-end)}]})
168+
:content (str think-tag-start (:text content) think-tag-end)})
171169
"assistant" {:role "assistant"
172170
:content (extract-content content supports-image?)}
173171
"system" {:role "system"
@@ -301,13 +299,12 @@
301299
(assoc tool-call :arguments {} :parse-error error-msg)))))))
302300
;; Filter out tool calls with parse errors to prevent execution with invalid data
303301
valid-tools (remove :parse-error completed-tools)]
304-
(if (seq valid-tools)
302+
(when (seq valid-tools)
305303
;; We have valid tools to execute, continue the conversation
306304
(let [tool-turn-id (str (random-uuid))
307305
tools-with-turn-id (mapv #(assoc % :tool-turn-id tool-turn-id) valid-tools)]
308-
(on-tools-called-wrapper tools-with-turn-id on-tools-called handle-response))
309-
;; No valid tools (all had parse errors or none accumulated) - don't loop
310-
nil)))
306+
(on-tools-called-wrapper tools-with-turn-id on-tools-called handle-response)
307+
tools-with-turn-id))))
311308

312309
(defn ^:private process-text-think-aware
313310
"Incremental parser that splits streamed content into user text and thinking blocks.
@@ -495,11 +492,15 @@
495492

496493
handle-response (fn handle-response [event data tool-calls*]
497494
(if (= event "stream-end")
498-
(do
495+
(let [had-tool-calls? (seq @tool-calls*)]
499496
;; Flush any leftover buffered content and finish reasoning if needed
500497
(flush-content-buffer)
501498
(finish-reasoning! reasoning-state* on-reason)
502-
(execute-accumulated-tools! tool-calls* on-tools-called-wrapper on-tools-called handle-response))
499+
(when (and had-tool-calls?
500+
(nil? (execute-accumulated-tools! tool-calls* on-tools-called-wrapper on-tools-called handle-response)))
501+
;; The stream ended with tool_calls, but none were executable (e.g. invalid JSON args).
502+
;; Emit :finish so the UI does not hang waiting for the next turn.
503+
(on-message-received {:type :finish :finish-reason "stop"})))
503504
(when (seq (:choices data))
504505
(doseq [choice (:choices data)]
505506
(let [delta (:delta choice)
@@ -578,7 +579,11 @@
578579
;; Handle reasoning completion
579580
(finish-reasoning! reasoning-state* on-reason)
580581
;; Handle regular finish
581-
(when (not= finish-reason "tool_calls")
582+
;; Some OpenAI-compatible providers (e.g. Google's) may emit finish_reason "stop"
583+
;; even when the turn contains tool_calls. In that case, defer :finish until after
584+
;; the tool loop completes, otherwise chat-level side effects may run too early.
585+
(when (and (not= finish-reason "tool_calls")
586+
(empty? @tool-calls*))
582587
(on-message-received {:type :finish :finish-reason finish-reason})))))))
583588
(when-let [usage (:usage data)]
584589
(on-usage-updated (parse-usage usage))))

test/eca/llm_providers/openai_chat_test.clj

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -53,17 +53,17 @@
5353
(deftest normalize-messages-test
5454
(testing "With tool_call history - assistant text and tool calls are merged"
5555
(is (match?
56-
[{:role "user" :content [{:type "text" :text "List the files"}]}
56+
[{:role "user" :content "List the files"}
5757
{:role "assistant"
58-
:content [{:type "text" :text "I'll list the files for you"}]
58+
:content "I'll list the files for you"
5959
:tool_calls [{:id "call-1"
6060
:type "function"
6161
:function {:name "eca__list_files"
6262
:arguments "{}"}}]}
6363
{:role "tool"
6464
:tool_call_id "call-1"
6565
:content "file1.txt\nfile2.txt\n"}
66-
{:role "assistant" :content [{:type "text" :text "I found 2 files"}]}]
66+
{:role "assistant" :content "I found 2 files"}]
6767
(#'llm-providers.openai-chat/normalize-messages
6868
[{:role "user" :content "List the files"}
6969
{:role "assistant" :content "I'll list the files for you"}
@@ -81,9 +81,8 @@
8181

8282
(testing "Reason messages without reasoning-content use think tags, merged with following assistant"
8383
(is (match?
84-
[{:role "user" :content [{:type "text" :text "Hello"}]}
85-
{:role "assistant" :content [{:type "text" :text "<think>Thinking...</think>"}
86-
{:type "text" :text "Hi"}]}]
84+
[{:role "user" :content "Hello"}
85+
{:role "assistant" :content "<think>Thinking...</think>\nHi"}]
8786
(#'llm-providers.openai-chat/normalize-messages
8887
[{:role "user" :content "Hello"}
8988
{:role "reason" :content {:text "Thinking..."}}
@@ -93,8 +92,8 @@
9392
thinking-end-tag)))))
9493

9594
(deftest extract-content-test
96-
(testing "String input"
97-
(is (= [{:type "text" :text "Hello world"}]
95+
(testing "String input - returns string for text-only content"
96+
(is (= "Hello world"
9897
(#'llm-providers.openai-chat/extract-content " Hello world " true))))
9998

10099
(testing "Sequential messages with actual format"
@@ -161,10 +160,10 @@
161160
thinking-end-tag))))
162161

163162
(testing "Reason messages - use reasoning_content if :delta-reasoning?, otherwise tags"
164-
;; Without :delta-reasoning?, uses think tags
163+
;; Without :delta-reasoning?, uses think tags (string, not array - for Gemini compatibility)
165164
(is (match?
166165
{:role "assistant"
167-
:content [{:type "text" :text "<think>Reasoning...</think>"}]}
166+
:content "<think>Reasoning...</think>"}
168167
(#'llm-providers.openai-chat/transform-message
169168
{:role "reason"
170169
:content {:text "Reasoning..."}}
@@ -372,13 +371,13 @@
372371
;; After normalization, all tool_calls should be merged into one assistant message
373372
;; followed by all tool outputs, then the final assistant message
374373
(is (match?
375-
[{:role "user" :content [{:type "text" :text "Read two files"}]}
374+
[{:role "user" :content "Read two files"}
376375
{:role "assistant"
377376
:tool_calls [{:id "call-1" :function {:name "eca__read_file"}}
378377
{:id "call-2" :function {:name "eca__read_file"}}]}
379378
{:role "tool" :tool_call_id "call-1" :content "content1\n"}
380379
{:role "tool" :tool_call_id "call-2" :content "content2\n"}
381-
{:role "assistant" :content [{:type "text" :text "I read both files"}]}]
380+
{:role "assistant" :content "I read both files"}]
382381
normalized)
383382
"Tool calls must be grouped together before their outputs")))
384383

@@ -421,11 +420,11 @@
421420
[{:role "user"}
422421
{:role "assistant" :tool_calls [{:id "call-1"}]}
423422
{:role "tool" :tool_call_id "call-1"}
424-
{:role "assistant" :content [{:type "text" :text "First response"}]}
423+
{:role "assistant" :content "First response"}
425424
{:role "user"}
426425
{:role "assistant" :tool_calls [{:id "call-2"}]}
427426
{:role "tool" :tool_call_id "call-2"}
428-
{:role "assistant" :content [{:type "text" :text "Second response"}]}]
427+
{:role "assistant" :content "Second response"}]
429428
normalized)))))
430429

431430
(deftest gemini-thought-signature-test
@@ -527,7 +526,7 @@
527526
{:role "assistant"
528527
:tool_calls [{:id "call-2" :function {:name "tool2"}}]}
529528
{:role "tool" :tool_call_id "call-2" :content "r2\n"}
530-
{:role "assistant" :content [{:type "text" :text "Done"}]}]
529+
{:role "assistant" :content "Done"}]
531530
normalized)))))
532531

533532
(deftest tool-call-order-by-index-test
@@ -768,7 +767,7 @@
768767
(is (= "think more" (:reason-text result)))
769768
(is (some? (:reasoning-content result)) "reasoning-content should be present in non-streaming result")
770769
(is (match?
771-
[{:role "user" :content [{:type "text" :text "Q"}]}
770+
[{:role "user" :content "Q"}
772771
{:role "assistant"
773772
:reasoning_content "think more"}]
774773
normalized)))))

0 commit comments

Comments
 (0)