branch: elpa/gptel
commit 195f240a61336d64bda950f6201db3eaec9ea060
Author: Karthik Chikmagalur <[email protected]>
Commit: Karthik Chikmagalur <[email protected]>
gptel-openai: Fix tool call handling when reasoning
* gptel-openai.el (gptel-curl--parse-stream,
gptel--parse-response): Some models (like deepseek-reasoner) call
tools when reasoning. When sending the tool results, they expect
the reasoning content they supplied to be sent back as well.
Capture reasoning content and inject it to the messages array
along with the tool call. (#1176)
Reasoning content in streaming responses is now captured in two
places in the INFO plist (:reasoning and :reasoning-chunks), where
the former is consumed by the stream filter, as before. The
latter is persistent until the end of the turn and used.
When parsing non-streaming responses, capture reasoning text
even if there is no other response text. This is expected when a
response consists only of reasoning text and a tool call.
* gptel-request.el (gptel-curl--sentinel): Similarly, call the
callback with the reasoning text even when there is no other
response text in a non-streaming response.
* NEWS (Notable bug fixes): Mention fix.
---
NEWS | 4 ++++
gptel-openai.el | 71 +++++++++++++++++++++++++++++++++++---------------------
gptel-request.el | 25 ++++++++++----------
3 files changed, 62 insertions(+), 38 deletions(-)
diff --git a/NEWS b/NEWS
index a469c3e2c66..7b55faa7e73 100644
--- a/NEWS
+++ b/NEWS
@@ -46,6 +46,10 @@
evaluated in a temporary buffer used to construct the query, leading
to unexpected behavior.)
+- When using OpenAI-compatible APIs (such as Deepseek), models that
+ call tools within their "reasoning" phase are now correctly handled
+ by gptel.
+
* 0.9.9.3
** Breaking changes
diff --git a/gptel-openai.el b/gptel-openai.el
index 79c3d071481..8bf54971638 100644
--- a/gptel-openai.el
+++ b/gptel-openai.el
@@ -181,8 +181,15 @@ Throw an error if there is no match."
;; chunks. We collect them in INFO -> :partial_json. The end of a tool call
;; chunk is marked by the beginning of another, or by the end of the stream.
In
;; either case we flaten the :partial_json we have thus far, add it to the tool
-;; call spec in :tool-use and reset it. Finally we append the tool calls to
the
-;; (INFO -> :data -> :messages) list of prompts.
+;; call spec in :tool-use and reset it.
+;;
+;; If we find reasoning text, collect it in INFO -> :reasoning, to be consumed
+;; by the stream filter (and eventually the callback). We also collect it in
+;; INFO -> :reasoning-chunks, in case we need to send it back along with tool
+;; call results.
+;;
+;; Finally we append any tool calls and accumulated reasoning text (from
+;; :reasoning-chunks) to the (INFO -> :data -> :messages) list of prompts.
(cl-defmethod gptel-curl--parse-stream ((_backend gptel-openai) info)
"Parse an OpenAI API data stream.
@@ -198,22 +205,29 @@ information if the stream contains it."
;; The stream has ended, so we do the following thing (if we
found tool calls)
;; - pack tool calls into the messages prompts list to send
(INFO -> :data -> :messages)
;; - collect tool calls (formatted differently) into (INFO ->
:tool-use)
- (when-let* ((tool-use (plist-get info :tool-use))
- (args (apply #'concat (nreverse (plist-get info
:partial_json))))
- (func (plist-get (car tool-use) :function)))
- (plist-put func :arguments args) ;Update arguments for last
recorded tool
- (gptel--inject-prompt
- (plist-get info :backend) (plist-get info :data)
- `(:role "assistant" :content :null :tool_calls ,(vconcat
tool-use))) ; :refusal :null
- (cl-loop
- for tool-call in tool-use ; Construct the call specs for
running the function calls
- for spec = (plist-get tool-call :function)
- collect (list :id (plist-get tool-call :id)
- :name (plist-get spec :name)
- :args (ignore-errors (gptel--json-read-string
- (plist-get spec
:arguments))))
- into call-specs
- finally (plist-put info :tool-use call-specs)))
+ ;; - Clear any reasoning content chunks we've captured
+ (progn
+ (when-let* ((tool-use (plist-get info :tool-use))
+ (args (apply #'concat (nreverse (plist-get info
:partial_json))))
+ (func (plist-get (car tool-use) :function)))
+ (plist-put func :arguments args) ;Update arguments for
last recorded tool
+ (gptel--inject-prompt
+ (plist-get info :backend) (plist-get info :data)
+ `( :role "assistant" :content :null :tool_calls ,(vconcat
tool-use) ; :refusal :null
+ ;; Return reasoning if available
+ ,@(and-let* ((chunks (nreverse (plist-get info
:reasoning-chunks)))
+ (reasoning-field (pop chunks))) ;chunks
is (:reasoning.* "chunk1" "chunk2" ...)
+ (list reasoning-field (apply #'concat chunks)))))
+ (cl-loop
+ for tool-call in tool-use ; Construct the call specs for
running the function calls
+ for spec = (plist-get tool-call :function)
+ collect (list :id (plist-get tool-call :id)
+ :name (plist-get spec :name)
+ :args (ignore-errors
(gptel--json-read-string
+ (plist-get spec
:arguments))))
+ into call-specs
+ finally (plist-put info :tool-use call-specs)))
+ (when (plist-member info :reasoning-chunks) (plist-put info
:reasoning-chunks nil)))
(when-let* ((response (gptel--json-read))
(delta (map-nested-elt response '(:choices 0
:delta))))
(if-let* ((content (plist-get delta :content))
@@ -240,11 +254,16 @@ information if the stream contains it."
(push (plist-get func :arguments) (plist-get info
:partial_json)))))
;; Check for reasoning blocks, currently only used by
Openrouter
(unless (eq (plist-get info :reasoning-block) 'done)
- (if-let* ((reasoning-chunk (or (plist-get delta :reasoning)
;for Openrouter and co
- (plist-get delta
:reasoning_content))) ;for Deepseek, Llama.cpp
+ (if-let* ((reasoning-plist ;reasoning-plist is (:reasoning.*
"chunk" ...) or nil
+ (or (plist-member delta :reasoning) ;for
Openrouter and co
+ (plist-member delta :reasoning_content)))
;for Deepseek, Llama.cpp
+ (reasoning-chunk (cadr reasoning-plist))
((not (or (eq reasoning-chunk :null)
(string-empty-p reasoning-chunk)))))
- (plist-put info :reasoning
- (concat (plist-get info :reasoning)
reasoning-chunk))
+ (progn (plist-put info :reasoning ;For stream filter
consumption
+ (concat (plist-get info :reasoning)
reasoning-chunk))
+ (plist-put info :reasoning-chunks ;To include
with tool call results, if any
+ (cons reasoning-chunk (or (plist-get
info :reasoning-chunks)
+ (list (car
reasoning-plist))))))
;; Done with reasoning if we get non-empty content
(if-let* (((plist-member info :reasoning)) ;Is this a
reasoning model?
(c (plist-get delta :content)) ;Started
receiving text content?
@@ -281,11 +300,11 @@ Mutate state INFO with response metadata."
(plist-put call-spec :id (plist-get tool-call :id))
collect call-spec into tool-use
finally (plist-put info :tool-use tool-use)))
+ (when-let* ((reasoning (or (plist-get message :reasoning) ;for Openrouter
and co
+ (plist-get message :reasoning_content))) ;for
Deepseek, Llama.cpp
+ ((and (stringp reasoning) (not (string-empty-p reasoning)))))
+ (plist-put info :reasoning reasoning))
(when (and content (not (or (eq content :null) (string-empty-p content))))
- (when-let* ((reasoning (or (plist-get message :reasoning) ;for
Openrouter and co
- (plist-get message :reasoning_content))) ;for
Deepseek, Llama.cpp
- ((and (stringp reasoning) (not (string-empty-p reasoning)))))
- (plist-put info :reasoning reasoning))
content)))
(cl-defmethod gptel--request-data ((backend gptel-openai) prompts)
diff --git a/gptel-request.el b/gptel-request.el
index 3623692bb25..5a4bfb88e70 100644
--- a/gptel-request.el
+++ b/gptel-request.el
@@ -2796,18 +2796,19 @@ PROCESS and _STATUS are process parameters."
(plist-put proc-info :status http-msg)
(gptel--fsm-transition fsm) ;WAIT -> TYPE
(when error (plist-put proc-info :error error))
- (when response ;Look for a reasoning block
- (if (string-match-p "^\\s-*<think>" response)
- (when-let* ((idx (string-search "</think>" response)))
- (with-demoted-errors "gptel callback error: %S"
- (funcall proc-callback
- (cons 'reasoning (substring response nil (+ idx 8)))
- proc-info))
- (setq response
- (string-trim-left (substring response (+ idx 8)))))
- (when-let* ((reasoning (plist-get proc-info :reasoning))
- ((stringp reasoning)))
- (funcall proc-callback (cons 'reasoning reasoning) proc-info))))
+ ;; Look for a reasoning block
+ (if (and (stringp response) (string-match-p "^\\s-*<think>" response))
+ (when-let* ((idx (string-search "</think>" response)))
+ (with-demoted-errors "gptel callback error: %S"
+ (funcall proc-callback
+ (cons 'reasoning (substring response nil (+ idx 8)))
+ proc-info))
+ (setq response
+ (string-trim-left (substring response (+ idx 8)))))
+ (when-let* ((reasoning (plist-get proc-info :reasoning))
+ ((stringp reasoning)))
+ (funcall proc-callback (cons 'reasoning reasoning) proc-info)))
+ ;; Call callback with response text
(when (or response (not (member http-status '("200" "100"))))
(with-demoted-errors "gptel callback error: %S"
(funcall proc-callback response proc-info))))