gptel-curl: Add curl module and playback feature.

Conditionally solves #2.

gptel.el (gptel-use-curl, gptel-parse-response, gptel--playback,
gptel-send, gptel-playback): New user options `gptel-playback',
`gptel-use-curl`. The former controls whether the response is played
back in chunks, which is done by the function `gptel--playback'. The
response returned by `gptel-get-response' and `gptel--curl-get-response'
is now a plist with the content and status.

gptel-curl.el (gptel--curl-get-args, gptel--curl-get-response,
gptel--curl-sentinel): Add support for curl when available.  Set it to
the default. `url-retrieve' is full of fangs that multibyte you.
This commit is contained in:
Karthik Chikmagalur 2023-03-08 00:52:48 -08:00
parent 33d8434f3e
commit 88995a6436
2 changed files with 246 additions and 53 deletions

141
gptel-curl.el Normal file
View file

@ -0,0 +1,141 @@
;;; gptel-curl.el --- Curl support for GPTel -*- lexical-binding: t; -*-
;; Copyright (C) 2023 Karthik Chikmagalur
;; Author: Karthik Chikmagalur;; <karthikchikmagalur@gmail.com>
;; Keywords: convenience
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; Curl support for GPTel. Utility functions.
;;; Code:
(eval-when-compile
(require 'subr-x))
(require 'map)
(require 'json)
(require 'aio)
(defvar gptel-api-key)
(defvar gptel--curl-process-alist nil
"Alist of active GPTel curl requests.")
(defun gptel--curl-get-args (prompts token)
"Produce list of arguments for calling Curl.
PROMPTS is the data to send, TOKEN is a unique identifier."
(let* ((args
(list "--location" "--silent" "--compressed" "--disable"))
(url "https://api.openai.com/v1/chat/completions")
(data (encode-coding-string
(json-encode
`(:model "gpt-3.5-turbo"
;; :temperature 1.0
;; :top_p 1.0
:messages [,@prompts]))
'utf-8))
(api-key
(cond
((stringp gptel-api-key) gptel-api-key)
((functionp gptel-api-key) (funcall gptel-api-key))))
(headers
`(("Content-Type" . "application/json")
("Authorization" . ,(concat "Bearer " api-key)))))
(push (format "-X%s" "POST") args)
(push (format "-w(%s . %%{size_header})" token) args)
;; (push (format "--keepalive-time %s" 240) args)
(push (format "-m%s" 60) args)
(push "-D-" args)
(pcase-dolist (`(,key . ,val) headers)
(push (format "-H%s: %s" key val) args))
(push (format "-d%s" data) args)
(nreverse (cons url args))))
(defun gptel--curl-get-response (prompts)
"Retrieve response to PROMPTS."
(with-current-buffer (generate-new-buffer "*gptel-curl*")
(let* ((token (md5 (format "%s%s%s%s"
(random) (emacs-pid) (user-full-name)
(recent-keys))))
(args (gptel--curl-get-args prompts token))
(process (apply #'start-process "gptel-curl" (current-buffer)
"curl" args))
(promise (aio-promise))
(cb (lambda (result)
(aio-resolve promise (lambda () result))
(setf (alist-get process
gptel--curl-process-alist nil 'remove)
nil))))
(prog1 promise
(set-process-query-on-exit-flag process nil)
(setf (alist-get process gptel--curl-process-alist)
(list :callback cb :token token))
(set-process-sentinel process #'gptel--curl-sentinel)))))
(defun gptel--curl-sentinel (process status)
"Process sentinel for GPTel curl requests.
PROCESS and STATUS are process parameters."
(let ((proc-buf (process-buffer process)))
(if-let* ((ok-p (equal status "finished\n"))
(proc-info (alist-get process gptel--curl-process-alist))
(proc-token (plist-get proc-info :token))
(content (gptel--curl-parse-response proc-buf proc-token)))
(funcall (plist-get proc-info :callback) content)
;; Failed
(funcall (plist-get proc-info :callback) nil))
(kill-buffer proc-buf)))
(defun gptel--curl-parse-response (buf token)
"Parse the buffer BUF with curl's response.
TOKEN is used to disambiguate multiple requests in a single
buffer."
(with-current-buffer buf
(progn
(goto-char (point-max))
(search-backward token)
(backward-char)
(pcase-let* ((`(,_ . ,header-size) (read (current-buffer))))
;; (if (search-backward token nil t)
;; (search-forward ")" nil t)
;; (goto-char (point-min)))
(goto-char (point-min))
(if-let* ((http-msg (buffer-substring (line-beginning-position)
(line-end-position)))
(http-status
(save-match-data
(and (string-match "HTTP/[.0-9]+ +\\([0-9]+\\)" http-msg)
(match-string 1 http-msg))))
(json-object-type 'plist)
(response (progn (goto-char header-size)
(json-read)))
(content (map-nested-elt
response '(:choices 0 :message :content))))
(cond
((not (equal http-status "200"))
(message "GPTChat request failed with code %s" http-status)
(list :content nil :status http-msg))
(content
(list :content (string-trim content) :status http-msg))
(t (message "Could not parse response from ChatGPT.")
(list :content nil :status http-msg))))))))
(provide 'gptel-curl)
;;; gptel-curl.el ends here

158
gptel.el
View file

@ -48,8 +48,11 @@
;;; Code: ;;; Code:
(declare-function markdown-mode "markdown-mode") (declare-function markdown-mode "markdown-mode")
(declare-function gptel--curl-get-response "gptel-curl")
(eval-when-compile (eval-when-compile
(require 'subr-x)) (require 'subr-x)
(require 'cl-lib))
(require 'aio) (require 'aio)
(require 'json) (require 'json)
@ -65,6 +68,20 @@ key (more secure)."
(string :tag "API key") (string :tag "API key")
(function :tag "Function that retuns the API key"))) (function :tag "Function that retuns the API key")))
(defcustom gptel-playback nil
"Whether responses from ChatGPT be played back in chunks.
When set to nil, it is inserted all at once.
'tis a bit silly."
:group 'gptel
:type 'boolean)
(defcustom gptel-use-curl (and (executable-find "curl") t)
"Whether gptel should prefer Curl when available."
:group 'gptel
:type 'boolean)
(defvar-local gptel--prompt-markers nil) (defvar-local gptel--prompt-markers nil)
(defvar gptel-default-session "*ChatGPT*") (defvar gptel-default-session "*ChatGPT*")
(defvar gptel-default-mode (if (featurep 'markdown-mode) (defvar gptel-default-mode (if (featurep 'markdown-mode)
@ -97,34 +114,42 @@ key (more secure)."
into prompts into prompts
do (setq role (if (equal role "user") "assistant" "user")) do (setq role (if (equal role "user") "assistant" "user"))
finally return (nreverse prompts)))) finally return (nreverse prompts))))
(response-buffer (aio-await (gptel-get-response full-prompt))) (response (aio-await
(json-object-type 'plist)) (funcall
(unwind-protect (if (and gptel-use-curl (require 'gptel-curl nil t))
(when-let* ((content-str (gptel-parse-response response-buffer))) #'gptel--curl-get-response #'gptel-get-response)
(with-current-buffer gptel-buffer full-prompt)))
(save-excursion (content-str (plist-get response :content))
(message "Querying ChatGPT... done.") (status-str (plist-get response :status)))
(goto-char (point-max)) (if content-str
(display-buffer (current-buffer) (with-current-buffer gptel-buffer
'((display-buffer-reuse-window (save-excursion
display-buffer-use-some-window))) (message "Querying ChatGPT... done.")
(unless (bobp) (insert "\n\n")) (goto-char (point-max))
;; (if gptel-playback-response (display-buffer (current-buffer)
;; (aio-await (gptel--playback-print content-str)) '((display-buffer-reuse-window
;; (insert content-str)) display-buffer-use-some-window)))
(insert content-str) (unless (bobp) (insert "\n\n"))
(push (set-marker (make-marker) (point)) (if gptel-playback
gptel--prompt-markers) (gptel--playback (current-buffer) content-str (point))
(insert "\n\n" gptel-prompt-string) (insert content-str))
(setf (nth 1 header-line-format) (push (set-marker (make-marker) (point))
(propertize " Ready" 'face 'success))))) gptel--prompt-markers)
(kill-buffer response-buffer)))) (insert "\n\n" gptel-prompt-string)
(unless gptel-playback
(setf (nth 1 header-line-format)
(propertize " Ready" 'face 'success)))))
(setf (nth 1 header-line-format)
(propertize (format " Response Error: %s" status-str)
'face 'error)))))
(aio-defun gptel-get-response (prompts) (aio-defun gptel-get-response (prompts)
"Fetch response for PROMPTS from ChatGPT. "Fetch response for PROMPTS from ChatGPT.
Return the response buffer." Return the message received."
(let* ((api-key (let* ((inhibit-message t)
(message-log-max nil)
(api-key
(cond (cond
((stringp gptel-api-key) gptel-api-key) ((stringp gptel-api-key) gptel-api-key)
((functionp gptel-api-key) (funcall gptel-api-key)))) ((functionp gptel-api-key) (funcall gptel-api-key))))
@ -133,19 +158,34 @@ Return the response buffer."
`(("Content-Type" . "application/json") `(("Content-Type" . "application/json")
("Authorization" . ,(concat "Bearer " api-key)))) ("Authorization" . ,(concat "Bearer " api-key))))
(url-request-data (url-request-data
(json-encode (encode-coding-string
(json-encode
`(:model "gpt-3.5-turbo" `(:model "gpt-3.5-turbo"
;; :temperature 1.0 ;; :temperature 1.0
;; :top_p 1.0 ;; :top_p 1.0
:messages [,@prompts])))) :messages [,@prompts]))
(let ((inhibit-message t) 'utf-8)))
(message-log-max nil)) (pcase-let ((`(,_ . ,response-buffer)
(pcase-let ((`(,_ . ,buffer) (aio-await
(aio-await (aio-url-retrieve "https://api.openai.com/v1/chat/completions"))))
(aio-url-retrieve "https://api.openai.com/v1/chat/completions")))) (prog1
buffer)))) (gptel-parse-response response-buffer)
(kill-buffer response-buffer)))))
(defun gptel-parse-response (response-buffer)
"Parse response in RESPONSE-BUFFER."
(when (buffer-live-p response-buffer)
(with-current-buffer response-buffer
(if-let* ((status (buffer-substring (line-beginning-position) (line-end-position)))
((string-match-p "200 OK" status))
(response (progn (forward-paragraph)
(json-read)))
(content (map-nested-elt
response '(:choices 0 :message :content))))
(list :content (string-trim content)
:status status)
(list :content nil :status status)))))
;;;###autoload
(define-minor-mode gptel-mode (define-minor-mode gptel-mode
"Minor mode for interacting with ChatGPT." "Minor mode for interacting with ChatGPT."
:glboal nil :glboal nil
@ -190,26 +230,38 @@ Ask for API-KEY if `gptel-api-key' is unset."
(message "Send your query with %s!" (message "Send your query with %s!"
(substitute-command-keys "\\[gptel-send]")))) (substitute-command-keys "\\[gptel-send]"))))
(defun gptel-parse-response (response-buffer) (defun gptel--playback (buf content-str start-pt)
"Parse response in RESPONSE-BUFFER." "Playback CONTENT-STR in BUF.
(when (buffer-live-p response-buffer)
(with-current-buffer response-buffer
(if-let* ((status (buffer-substring (line-beginning-position) (line-end-position)))
((string-match "200 OK" status))
(response (progn (forward-paragraph)
(json-read)))
(content (map-nested-elt
response '(:choices 0 :message :content))))
(string-trim content)
(user-error "Chat failed with status: %S" status)))))
(defvar gptel-playback-response t) Begin at START-PT."
(let ((handle (gensym "gptel-change-group-handle--"))
(aio-defun gptel--playback-print (response) (playback-timer (gensym "gptel--playback-"))
(when response (content-length (length content-str))
(dolist (line (split-string response "\n" nil)) (idx 0) (pt (make-marker)))
(insert line "\n") (setf (symbol-value handle) (prepare-change-group buf))
(aio-await (aio-sleep 0.3))))) (activate-change-group (symbol-value handle))
(setf (symbol-value playback-timer)
(run-at-time
0 0.15
(lambda ()
(with-current-buffer buf
(if (>= content-length idx)
(progn
(when (= idx 0) (set-marker pt start-pt))
(goto-char pt)
(insert-before-markers-and-inherit
(cl-subseq
content-str idx
(min content-length (+ idx 16))))
(setq idx (+ idx 16)))
(when start-pt (goto-char (- start-pt 2)))
(setf (nth 1 header-line-format)
(propertize " Ready" 'face 'success))
(force-mode-line-update)
(accept-change-group (symbol-value handle))
(undo-amalgamate-change-group (symbol-value handle))
(cancel-timer (symbol-value playback-timer)))))))
nil))
(provide 'gptel) (provide 'gptel)
;;; gptel.el ends here ;;; gptel.el ends here