Skip to content

Commit 3a61bc6

Browse files
authored
Merge pull request #243 from editor-code-assistant/fix-openai-chat-toolcall-id-streaming
Fix OpenAI chat tool_call id handling in streaming
2 parents 1b52a76 + 1cbb57c commit 3a61bc6

File tree

4 files changed

+55
-35
lines changed

4 files changed

+55
-35
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Changelog
22

33
## Unreleased
4+
- Fix openai-chat tool call + support for Mistral API #233
45

56
## 0.87.1
67

src/eca/features/chat.clj

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,21 +1021,31 @@
10211021
(run-pre-request-hooks! (assoc chat-ctx :message original-text))]
10221022
(cond
10231023
stop? (do (finish-chat-prompt! :idle chat-ctx) nil)
1024-
:else (let [last-user-idx (or (llm-util/find-last-user-msg-idx user-messages)
1025-
(dec (count user-messages)))
1026-
rewritten (if (and modify-allowed?
1027-
last-user-idx
1028-
final-prompt)
1024+
:else (let [last-user-idx (llm-util/find-last-user-msg-idx user-messages)
1025+
;; preRequest additionalContext should ideally attach to the last user message,
1026+
;; but some prompt sources may not contain a user role (e.g. prompt templates).
1027+
context-idx (or last-user-idx
1028+
(some-> user-messages seq count dec))
1029+
rewritten (if (and modify-allowed? last-user-idx final-prompt)
10291030
(assoc-in user-messages [last-user-idx :content 0 :text] final-prompt)
10301031
user-messages)
1031-
with-contexts (if (seq additional-contexts)
1032+
with-contexts (cond
1033+
(and (seq additional-contexts) context-idx)
10321034
(reduce (fn [msgs {:keys [hook-name content]}]
1033-
(update-in msgs [last-user-idx :content]
1035+
(update-in msgs [context-idx :content]
10341036
#(conj (vec %)
10351037
{:type :text
10361038
:text (wrap-additional-context hook-name content)})))
10371039
rewritten
10381040
additional-contexts)
1041+
1042+
(seq additional-contexts)
1043+
(do (logger/warn logger-tag "Dropping preRequest additionalContext because no message index was found"
1044+
{:source-type source-type
1045+
:num-messages (count user-messages)})
1046+
rewritten)
1047+
1048+
:else
10391049
rewritten)]
10401050
with-contexts)))
10411051
user-messages)]

src/eca/features/prompt.clj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@
8080
"")))
8181
""
8282
refined-contexts)
83-
;; TODO - should be refined contexts?
8483
(when startup-ctx
8584
(str "\n<additionalContext from=\"chatStart\">\n" startup-ctx "\n</additionalContext>\n\n"))
8685
"</contexts>"))

src/eca/llm_providers/openai_chat.clj

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -396,9 +396,8 @@
396396

397397
;; Atom to accumulate tool call data from streaming chunks.
398398
;; OpenAI streams tool call arguments across multiple chunks, so we need to
399-
;; accumulate the partial JSON strings before parsing them. Keys are either
400-
;; index numbers for simple cases, or "index-id" composite keys for parallel
401-
;; tool calls that share the same index but have different IDs.
399+
;; accumulate partial JSON strings before parsing them. Keys are tool call
400+
;; indices (fallback: IDs) to keep chunks grouped for the active response.
402401
tool-calls* (atom {})
403402

404403
;; Reasoning state machine:
@@ -431,6 +430,12 @@
431430
:content ""
432431
:buffer "")
433432
(on-reason {:status :started :id new-reason-id})))
433+
find-existing-tool-key (fn [tool-calls index id]
434+
(some (fn [[k v]] (when (or (some-> id (= (:id v)))
435+
(and (nil? (:id v))
436+
(some-> index (= (:index v)))))
437+
k))
438+
tool-calls))
434439
on-tools-called-wrapper (fn on-tools-called-wrapper [tools-to-call on-tools-called handle-response]
435440
(when-let [{:keys [new-messages]} (on-tools-called tools-to-call)]
436441
(let [pruned-messages (prune-history new-messages)
@@ -449,9 +454,9 @@
449454
:api-key api-key
450455
:url-relative-path url-relative-path
451456
:on-error wrapped-on-error
452-
:on-stream (when stream? (fn [event data] (handle-response event data tool-calls* new-rid)))}))))
457+
:on-stream (when stream? (fn [event data] (handle-response event data tool-calls*)))}))))
453458

454-
handle-response (fn handle-response [event data tool-calls* rid]
459+
handle-response (fn handle-response [event data tool-calls*]
455460
(if (= event "stream-end")
456461
(do
457462
;; Flush any leftover buffered content and finish reasoning if needed
@@ -496,27 +501,32 @@
496501
{name :name args :arguments} function
497502
;; Extract Google Gemini thought signature if present
498503
thought-signature (get-in extra_content [:google :thought_signature])
499-
;; Use RID as key to avoid collisions between API requests
500-
tool-key (str rid "-" index)
501-
;; Create globally unique tool call ID for client
502-
unique-id (when id (str rid "-" id))]
503-
(when (and name unique-id)
504-
(on-prepare-tool-call {:id unique-id
505-
:full-name name
506-
:arguments-text ""}))
507-
(swap! tool-calls* update tool-key
508-
(fn [existing]
509-
(cond-> (or existing {:index index})
510-
unique-id (assoc :id unique-id)
511-
name (assoc :full-name name)
512-
args (update :arguments-text (fnil str "") args)
513-
;; Store thought signature for Google Gemini
514-
thought-signature (assoc :external-id thought-signature))))
515-
(when-let [updated-tool-call (get @tool-calls* tool-key)]
516-
(when (and (:id updated-tool-call)
517-
(:full-name updated-tool-call)
518-
args)
519-
(on-prepare-tool-call (assoc updated-tool-call :arguments-text args)))))))
504+
existing-key (find-existing-tool-key @tool-calls* index id)
505+
existing (when existing-key (get @tool-calls* existing-key))
506+
tool-key (or existing-key index id)]
507+
(if (nil? tool-key)
508+
(logger/warn logger-tag "Received tool_call delta without index/id; ignoring"
509+
{:tool-call tool-call})
510+
(do
511+
(swap! tool-calls* update tool-key
512+
(fn [existing]
513+
(cond-> (or existing {:index index})
514+
(some? index) (assoc :index index)
515+
(and id (nil? (:id existing))) (assoc :id id)
516+
(and name (nil? (:full-name existing))) (assoc :full-name name)
517+
args (update :arguments-text (fnil str "") args)
518+
;; Store thought signature for Google Gemini
519+
thought-signature (assoc :external-id thought-signature))))
520+
(when-let [updated-tool-call (get @tool-calls* tool-key)]
521+
;; Streaming tool_calls may split metadata (id/name) and arguments across deltas.
522+
;; Emit prepare once we can correlate the call (id + full-name), on first id or args deltas,
523+
;; so :tool-prepare always precedes :tool-run in the tool-call state machine.
524+
(when (and (:id updated-tool-call)
525+
(:full-name updated-tool-call)
526+
(or (nil? (:id existing)) args))
527+
(on-prepare-tool-call
528+
(assoc updated-tool-call
529+
:arguments-text (or args ""))))))))))
520530
;; Process finish reason if present (but not tool_calls which is handled above)
521531
(when finish-reason
522532
;; Flush any leftover buffered content before finishing
@@ -541,4 +551,4 @@
541551
:on-tools-called-wrapper on-tools-called-wrapper
542552
:on-error wrapped-on-error
543553
:on-stream (when stream?
544-
(fn [event data] (handle-response event data tool-calls* rid)))})))
554+
(fn [event data] (handle-response event data tool-calls*)))})))

0 commit comments

Comments
 (0)