diff --git a/README.org b/README.org
index 9486ff0..77259ef 100644
--- a/README.org
+++ b/README.org
@@ -1,20 +1,34 @@
-#+title: GPTel: A simple ChatGPT client for Emacs
+#+title: GPTel: A simple LLM client for Emacs
[[https://melpa.org/#/gptel][file:https://melpa.org/packages/gptel-badge.svg]]
-GPTel is a simple, no-frills ChatGPT client for Emacs.
+GPTel is a simple Large Language Model chat client for Emacs, with support for multiple models/backends.
+
+| LLM Backend | Supports | Requires |
+|-------------+----------+------------------------|
+| ChatGPT | ✓ | [[https://platform.openai.com/account/api-keys][API key]] |
+| Azure | ✓ | Deployment and API key |
+| Ollama | ✓ | An LLM running locally |
+| GPT4All | ✓ | An LLM running locally |
+| PrivateGPT | Planned | - |
+| Llama.cpp | Planned | - |
+
+*General usage*:
https://user-images.githubusercontent.com/8607532/230516812-86510a09-a2fb-4cbd-b53f-cc2522d05a13.mp4
https://user-images.githubusercontent.com/8607532/230516816-ae4a613a-4d01-4073-ad3f-b66fa73c6e45.mp4
-- Requires an [[https://platform.openai.com/account/api-keys][OpenAI API key]].
+*Multi-LLM support demo*:
+
+https://github-production-user-asset-6210df.s3.amazonaws.com/8607532/278854024-ae1336c4-5b87-41f2-83e9-e415349d6a43.mp4
+
- It's async and fast, streams responses.
-- Interact with ChatGPT from anywhere in Emacs (any buffer, shell, minibuffer, wherever)
-- ChatGPT's responses are in Markdown or Org markup.
+- Interact with LLMs from anywhere in Emacs (any buffer, shell, minibuffer, wherever)
+- LLM responses are in Markdown or Org markup.
- Supports conversations and multiple independent sessions.
- Save chats as regular Markdown/Org/Text files and resume them later.
-- You can go back and edit your previous prompts, or even ChatGPT's previous responses when continuing a conversation. These will be fed back to ChatGPT.
+- You can go back and edit your previous prompts or LLM responses when continuing a conversation. These will be fed back to the model.
GPTel uses Curl if available, but falls back to url-retrieve to work without external dependencies.
@@ -25,13 +39,20 @@ GPTel uses Curl if available, but falls back to url-retrieve to work without ext
- [[#manual][Manual]]
- [[#doom-emacs][Doom Emacs]]
- [[#spacemacs][Spacemacs]]
+ - [[#setup][Setup]]
+ - [[#chatgpt][ChatGPT]]
+ - [[#other-llm-backends][Other LLM backends]]
+ - [[#azure][Azure]]
+ - [[#gpt4all][GPT4All]]
+ - [[#ollama][Ollama]]
- [[#usage][Usage]]
- [[#in-any-buffer][In any buffer:]]
- [[#in-a-dedicated-chat-buffer][In a dedicated chat buffer:]]
+ - [[#save-and-restore-your-chat-sessions][Save and restore your chat sessions]]
- [[#using-it-your-way][Using it your way]]
- [[#extensions-using-gptel][Extensions using GPTel]]
- [[#additional-configuration][Additional Configuration]]
- - [[#why-another-chatgpt-client][Why another ChatGPT client?]]
+ - [[#why-another-llm-client][Why another LLM client?]]
- [[#will-you-add-feature-x][Will you add feature X?]]
- [[#alternatives][Alternatives]]
- [[#acknowledgments][Acknowledgments]]
@@ -41,7 +62,7 @@ GPTel uses Curl if available, but falls back to url-retrieve to work without ext
** Installation
-GPTel is on MELPA. Install it with =M-x package-install⏎= =gptel=.
+GPTel is on MELPA. Ensure that MELPA is in your list of sources, then install gptel with =M-x package-install⏎= =gptel=.
(Optional: Install =markdown-mode=.)
@@ -84,9 +105,8 @@ After installation with =M-x package-install⏎= =gptel=
- Add =gptel= to =dotspacemacs-additional-packages=
- Add =(require 'gptel)= to =dotspacemacs/user-config=
#+html:
-
-** Usage
-
+** Setup
+*** ChatGPT
Procure an [[https://platform.openai.com/account/api-keys][OpenAI API key]].
Optional: Set =gptel-api-key= to the key. Alternatively, you may choose a more secure method such as:
@@ -97,6 +117,72 @@ machine api.openai.com login apikey password TOKEN
#+end_src
- Setting it to a function that returns the key.
+*** Other LLM backends
+#+html:
+**** Azure
+#+html:
+
+Register a backend with
+#+begin_src emacs-lisp
+(gptel-make-azure
+ "Azure-1" ;Name, whatever you'd like
+ :protocol "https" ;optional -- https is the default
+ :host "YOUR_RESOURCE_NAME.openai.azure.com"
+ :endpoint "/openai/deployments/YOUR_DEPLOYMENT_NAME/completions?api-version=2023-05-15" ;or equivalent
+ :stream t ;Enable streaming responses
+ :models '("gpt-3.5-turbo" "gpt-4"))
+#+end_src
+Refer to the documentation of =gptel-make-azure= to set more parameters.
+
+You can pick this backend from the transient menu when using gptel. (See usage)
+
+If you want it to be the default, set it as the default value of =gptel-backend=:
+#+begin_src emacs-lisp
+(setq-default gptel-backend
+ (gptel-make-azure
+ "Azure-1"
+ ...))
+#+end_src
+#+html:
+
+#+html:
+**** GPT4All
+#+html:
+
+Register a backend with
+#+begin_src emacs-lisp
+(gptel-make-gpt4all
+ "GPT4All" ;Name of your choosing
+ :protocol "http"
+ :host "localhost:4891" ;Where it's running
+ :models '("mistral-7b-openorca.Q4_0.gguf")) ;Available models
+#+end_src
+These are the required parameters, refer to the documentation of =gptel-make-gpt4all= for more.
+
+You can pick this backend from the transient menu when using gptel (see usage), or set this as the default value of =gptel-backend=.
+
+#+html:
+
+#+html:
+**** Ollama
+#+html:
+
+Register a backend with
+#+begin_src emacs-lisp
+(defvar gptel--ollama
+ (gptel-make-ollama
+ "Ollama" ;Any name of your choosing
+ :host "localhost:11434" ;Where it's running
+ :models '("mistral:latest") ;Installed models
+ :stream t)) ;Stream responses
+#+end_src
+These are the required parameters, refer to the documentation of =gptel-make-gpt4all= for more.
+
+You can pick this backend from the transient menu when using gptel (see usage), or set this as the default value of =gptel-backend=.
+
+#+html:
+
+** Usage
*** In any buffer:
1. Select a region of text and call =M-x gptel-send=. The response will be inserted below your region.
@@ -122,11 +208,11 @@ With a region selected, you can also rewrite prose or refactor code from here:
*** In a dedicated chat buffer:
-1. Run =M-x gptel= to start or switch to the ChatGPT buffer. It will ask you for the key if you skipped the previous step. Run it with a prefix-arg (=C-u M-x gptel=) to start a new session.
+1. Run =M-x gptel= to start or switch to the chat buffer. It will ask you for the key if you skipped the previous step. Run it with a prefix-arg (=C-u M-x gptel=) to start a new session.
2. In the gptel buffer, send your prompt with =M-x gptel-send=, bound to =C-c RET=.
-3. Set chat parameters (GPT model, directives etc) for the session by calling =gptel-send= with a prefix argument (=C-u C-c RET=):
+3. Set chat parameters (LLM provider, model, directives etc) for the session by calling =gptel-send= with a prefix argument (=C-u C-c RET=):
[[https://user-images.githubusercontent.com/8607532/224946059-9b918810-ab8b-46a6-b917-549d50c908f2.png]]
@@ -157,17 +243,29 @@ These are packages that depend on GPTel to provide additional functionality
- [[https://github.com/kamushadenes/ai-blog.el][ai-blog.el]]: Streamline generation of blog posts in Hugo.
** Additional Configuration
+:PROPERTIES:
+:ID: f885adac-58a3-4eba-a6b7-91e9e7a17829
+:END:
-- =gptel-host=: Overrides the OpenAI API host. This is useful for those who transform Azure API into OpenAI API format, utilize reverse proxy, or employ third-party proxy services for the OpenAI API.
+#+begin_src emacs-lisp :exports none
+(let ((all))
+ (mapatoms (lambda (sym)
+ (when (and (string-match-p "^gptel-[^-]" (symbol-name sym))
+ (get sym 'variable-documentation))
+ (push sym all))))
+ all)
+#+end_src
+
+- =gptel-stream=: Stream responses (if the model supports streaming). Defaults to true.
- =gptel-proxy=: Path to a proxy to use for GPTel interactions. This is passed to Curl via the =--proxy= argument.
-** Why another ChatGPT client?
+** Why another LLM client?
-Other Emacs clients for ChatGPT prescribe the format of the interaction (a comint shell, org-babel blocks, etc). I wanted:
+Other Emacs clients for LLMs prescribe the format of the interaction (a comint shell, org-babel blocks, etc). I wanted:
-1. Something that is as free-form as possible: query ChatGPT using any text in any buffer, and redirect the response as required. Using a dedicated =gptel= buffer just adds some visual flair to the interaction.
-2. Integration with org-mode, not using a walled-off org-babel block, but as regular text. This way ChatGPT can generate code blocks that I can run.
+1. Something that is as free-form as possible: query the model using any text in any buffer, and redirect the response as required. Using a dedicated =gptel= buffer just adds some visual flair to the interaction.
+2. Integration with org-mode, not using a walled-off org-babel block, but as regular text. This way the model can generate code blocks that I can run.
** Will you add feature X?
@@ -183,13 +281,14 @@ Maybe, I'd like to experiment a bit more first. Features added since the incept
- A built-in refactor/rewrite prompt
- Limiting conversation context to Org headings using properties (#58)
- Saving and restoring chats (#17)
+- Support for local LLMs.
Features being considered or in the pipeline:
- Fully stateless design (#17)
** Alternatives
-Other Emacs clients for ChatGPT include
+Other Emacs clients for LLMs include
- [[https://github.com/xenodium/chatgpt-shell][chatgpt-shell]]: comint-shell based interaction with ChatGPT. Also supports DALL-E, executable code blocks in the responses, and more.
- [[https://github.com/rksm/org-ai][org-ai]]: Interaction through special =#+begin_ai ... #+end_ai= Org-mode blocks. Also supports DALL-E, querying ChatGPT with the contents of project files, and more.
diff --git a/gptel-curl.el b/gptel-curl.el
index d6fc08f..6a02117 100644
--- a/gptel-curl.el
+++ b/gptel-curl.el
@@ -41,14 +41,16 @@
"Produce list of arguments for calling Curl.
PROMPTS is the data to send, TOKEN is a unique identifier."
- (let* ((url (format "%s://%s/v1/chat/completions"
- gptel-protocol gptel-host))
+ (let* ((url (gptel-backend-url gptel-backend))
(data (encode-coding-string
- (json-encode (gptel--request-data prompts))
+ (json-encode (gptel--request-data gptel-backend prompts))
'utf-8))
(headers
- `(("Content-Type" . "application/json")
- ("Authorization" . ,(concat "Bearer " (gptel--api-key))))))
+ (append '(("Content-Type" . "application/json"))
+ (when-let ((backend-header (gptel-backend-header gptel-backend)))
+ (if (functionp backend-header)
+ (funcall backend-header)
+ backend-header)))))
(append
(list "--location" "--silent" "--compressed" "--disable"
(format "-X%s" "POST")
@@ -81,14 +83,33 @@ the response is inserted into the current buffer after point."
(random) (emacs-pid) (user-full-name)
(recent-keys))))
(args (gptel-curl--get-args (plist-get info :prompt) token))
+ (stream (and gptel-stream (gptel-backend-stream gptel-backend)))
(process (apply #'start-process "gptel-curl"
(generate-new-buffer "*gptel-curl*") "curl" args)))
+ (when gptel--debug
+ (message "%S" args))
(with-current-buffer (process-buffer process)
(set-process-query-on-exit-flag process nil)
(setf (alist-get process gptel-curl--process-alist)
(nconc (list :token token
+ ;; FIXME `aref' breaks `cl-struct' abstraction boundary
+ ;; FIXME `cl--generic-method' is an internal `cl-struct'
+ :parser (cl--generic-method-function
+ (if stream
+ (cl-find-method
+ 'gptel-curl--parse-stream nil
+ (list
+ (aref (buffer-local-value
+ 'gptel-backend (plist-get info :buffer))
+ 0) t))
+ (cl-find-method
+ 'gptel--parse-response nil
+ (list
+ (aref (buffer-local-value
+ 'gptel-backend (plist-get info :buffer))
+ 0) t t))))
:callback (or callback
- (if gptel-stream
+ (if stream
#'gptel-curl--stream-insert-response
#'gptel--insert-response))
:transformer (when (eq (buffer-local-value
@@ -97,7 +118,7 @@ the response is inserted into the current buffer after point."
'org-mode)
(gptel--stream-convert-markdown->org)))
info))
- (if gptel-stream
+ (if stream
(progn (set-process-sentinel process #'gptel-curl--stream-cleanup)
(set-process-filter process #'gptel-curl--stream-filter))
(set-process-sentinel process #'gptel-curl--sentinel)))))
@@ -252,22 +273,21 @@ See `gptel--url-get-response' for details."
(when (equal http-status "200")
(funcall (or (plist-get proc-info :callback)
#'gptel-curl--stream-insert-response)
- (let* ((json-object-type 'plist)
- (content-strs))
- (condition-case nil
- (while (re-search-forward "^data:" nil t)
- (save-match-data
- (unless (looking-at " *\\[DONE\\]")
- (when-let* ((response (json-read))
- (delta (map-nested-elt
- response '(:choices 0 :delta)))
- (content (plist-get delta :content)))
- (push content content-strs)))))
- (error
- (goto-char (match-beginning 0))))
- (apply #'concat (nreverse content-strs)))
+ (funcall (plist-get proc-info :parser) nil proc-info)
proc-info))))))
+(cl-defgeneric gptel-curl--parse-stream (backend proc-info)
+ "Stream parser for gptel-curl.
+
+Implementations of this function run as part of the process
+filter for the active query, and return partial responses from
+the LLM.
+
+BACKEND is the LLM backend in use.
+
+PROC-INFO is a plist with process information and other context.
+See `gptel-curl--get-response' for its contents.")
+
(defun gptel-curl--sentinel (process _status)
"Process sentinel for GPTel curl requests.
@@ -278,61 +298,58 @@ PROCESS and _STATUS are process parameters."
(clone-buffer "*gptel-error*" 'show)))
(when-let* (((eq (process-status process) 'exit))
(proc-info (alist-get process gptel-curl--process-alist))
- (proc-token (plist-get proc-info :token))
(proc-callback (plist-get proc-info :callback)))
(pcase-let ((`(,response ,http-msg ,error)
- (gptel-curl--parse-response proc-buf proc-token)))
+ (with-current-buffer proc-buf
+ (gptel-curl--parse-response proc-info))))
(plist-put proc-info :status http-msg)
(when error (plist-put proc-info :error error))
(funcall proc-callback response proc-info)))
(setf (alist-get process gptel-curl--process-alist nil 'remove) nil)
(kill-buffer proc-buf)))
-(defun gptel-curl--parse-response (buf token)
+(defun gptel-curl--parse-response (proc-info)
"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))
+ (let ((token (plist-get proc-info :token))
+ (parser (plist-get proc-info :parser)))
+ (goto-char (point-max))
+ (search-backward token)
+ (backward-char)
+ (pcase-let* ((`(,_ . ,header-size) (read (current-buffer))))
+ (goto-char (point-min))
- (if-let* ((http-msg (string-trim
- (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)
- (condition-case nil
- (json-read)
- (json-readtable-error 'json-read-error)))))
- (cond
- ((equal http-status "200")
- (list (string-trim
- (map-nested-elt response '(:choices 0 :message :content)))
- http-msg))
- ((plist-get response :error)
- (let* ((error-plist (plist-get response :error))
- (error-msg (plist-get error-plist :message))
- (error-type (plist-get error-plist :type)))
- (list nil (concat "(" http-msg ") " (string-trim error-type)) error-msg)))
- ((eq response 'json-read-error)
- (list nil (concat "(" http-msg ") Malformed JSON in response.")
- "Malformed JSON in response"))
- (t (list nil (concat "(" http-msg ") Could not parse HTTP response.")
- "Could not parse HTTP response.")))
- (list nil (concat "(" http-msg ") Could not parse HTTP response.")
- "Could not parse HTTP response."))))))
+ (if-let* ((http-msg (string-trim
+ (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)
+ (condition-case nil
+ (json-read)
+ (json-readtable-error 'json-read-error)))))
+ (cond
+ ((equal http-status "200")
+ (list (string-trim
+ (funcall parser nil response proc-info))
+ http-msg))
+ ((plist-get response :error)
+ (let* ((error-plist (plist-get response :error))
+ (error-msg (plist-get error-plist :message))
+ (error-type (plist-get error-plist :type)))
+ (list nil (concat "(" http-msg ") " (string-trim error-type)) error-msg)))
+ ((eq response 'json-read-error)
+ (list nil (concat "(" http-msg ") Malformed JSON in response.")
+ "Malformed JSON in response"))
+ (t (list nil (concat "(" http-msg ") Could not parse HTTP response.")
+ "Could not parse HTTP response.")))
+ (list nil (concat "(" http-msg ") Could not parse HTTP response.")
+ "Could not parse HTTP response.")))))
(provide 'gptel-curl)
;;; gptel-curl.el ends here
diff --git a/gptel-ollama.el b/gptel-ollama.el
new file mode 100644
index 0000000..2d81347
--- /dev/null
+++ b/gptel-ollama.el
@@ -0,0 +1,143 @@
+;;; gptel-ollama.el --- Ollama support for gptel -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2023 Karthik Chikmagalur
+
+;; Author: Karthik Chikmagalur
+;; Keywords: hypermedia
+
+;; 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 .
+
+;;; Commentary:
+
+;; This file adds support for the Ollama LLM API to gptel
+
+;;; Code:
+(require 'gptel)
+(require 'cl-generic)
+
+;;; Ollama
+(cl-defstruct (gptel-ollama (:constructor gptel--make-ollama)
+ (:copier nil)
+ (:include gptel-backend)))
+
+(cl-defmethod gptel-curl--parse-stream ((_backend gptel-ollama) info)
+ ";TODO: "
+ (when (bobp)
+ (re-search-forward "^{")
+ (forward-line 0))
+ (let* ((json-object-type 'plist)
+ (content-strs)
+ (content))
+ (condition-case nil
+ (while (setq content (json-read))
+ (let ((done (map-elt content :done))
+ (response (map-elt content :response)))
+ (push response content-strs)
+ (unless (eq done json-false)
+ (with-current-buffer (plist-get info :buffer)
+ (setq gptel--ollama-context (map-elt content :context)))
+ (end-of-buffer))))
+ (error (forward-line 0)))
+ (apply #'concat (nreverse content-strs))))
+
+(cl-defmethod gptel--parse-response ((_backend gptel-ollama) response info)
+ (when-let ((context (map-elt response :context)))
+ (with-current-buffer (plist-get info :buffer)
+ (setq gptel--ollama-context context)))
+ (map-elt response :response))
+
+(cl-defmethod gptel--request-data ((_backend gptel-ollama) prompts)
+ "JSON encode PROMPTS for sending to ChatGPT."
+ (let ((prompts-plist
+ `(:model ,gptel-model
+ ,@prompts
+ :stream ,(or (and gptel-stream gptel-use-curl
+ (gptel-backend-stream gptel-backend))
+ :json-false))))
+ (when gptel--ollama-context
+ (plist-put prompts-plist :context gptel--ollama-context))
+ prompts-plist))
+
+(cl-defmethod gptel--parse-buffer ((_backend gptel-ollama) &optional _max-entries)
+ (let ((prompts) (prop))
+ (setq prop (text-property-search-backward
+ 'gptel 'response
+ (when (get-char-property (max (point-min) (1- (point)))
+ 'gptel)
+ t)))
+ (if (prop-match-value prop)
+ (user-error "No user prompt found!")
+ (setq prompts (list
+ :system gptel--system-message
+ :prompt
+ (string-trim (buffer-substring-no-properties (prop-match-beginning prop)
+ (prop-match-end prop))
+ "[*# \t\n\r]+"))))))
+
+;;;###autoload
+(cl-defun gptel-make-ollama
+ (name &key host header key models stream
+ (protocol "http")
+ (endpoint "/api/generate"))
+ "Register an Ollama backend for gptel with NAME.
+
+Keyword arguments:
+
+HOST is where Ollama runs (with port), typically localhost:11434
+
+MODELS is a list of available model names.
+
+STREAM is a boolean to toggle streaming responses, defaults to
+false.
+
+PROTOCOL (optional) specifies the protocol, http by default.
+
+ENDPOINT (optional) is the API endpoint for completions, defaults to
+\"/api/generate\".
+
+HEADER (optional) is for additional headers to send with each
+request. It should be an alist or a function that retuns an
+alist, like:
+((\"Content-Type\" . \"application/json\"))
+
+KEY (optional) is a variable whose value is the API key, or
+function that returns the key. This is typically not required for
+local models like Ollama."
+ (let ((backend (gptel--make-ollama
+ :name name
+ :host host
+ :header header
+ :key key
+ :models models
+ :protocol protocol
+ :endpoint endpoint
+ :stream stream
+ :url (if protocol
+ (concat protocol "://" host endpoint)
+ (concat host endpoint)))))
+ (prog1 backend
+ (setf (alist-get name gptel--known-backends
+ nil nil #'equal)
+ backend))))
+
+(defvar-local gptel--ollama-context nil
+ "Context for ollama conversations.
+
+This variable holds the context array for conversations with
+Ollama models.")
+
+(provide 'gptel-ollama)
+;;; gptel-ollama.el ends here
+
+
diff --git a/gptel-openai.el b/gptel-openai.el
new file mode 100644
index 0000000..316efc4
--- /dev/null
+++ b/gptel-openai.el
@@ -0,0 +1,216 @@
+;;; gptel-openai.el --- ChatGPT suppport for gptel -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2023 Karthik Chikmagalur
+
+;; Author: Karthik Chikmagalur
+;; Keywords:
+
+;; 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 .
+
+;;; Commentary:
+
+;; This file adds support for the ChatGPT API to gptel
+
+;;; Code:
+(require 'cl-generic)
+
+;;; Common backend struct for LLM support
+(cl-defstruct
+ (gptel-backend (:constructor gptel--make-backend)
+ (:copier gptel--copy-backend))
+ name host header protocol stream
+ endpoint key models url)
+
+;;; OpenAI (ChatGPT)
+(cl-defstruct (gptel-openai (:constructor gptel--make-openai)
+ (:copier nil)
+ (:include gptel-backend)))
+
+(cl-defmethod gptel-curl--parse-stream ((_backend gptel-openai) _info)
+ (let* ((json-object-type 'plist)
+ (content-strs))
+ (condition-case nil
+ (while (re-search-forward "^data:" nil t)
+ (save-match-data
+ (unless (looking-at " *\\[DONE\\]")
+ (when-let* ((response (json-read))
+ (delta (map-nested-elt
+ response '(:choices 0 :delta)))
+ (content (plist-get delta :content)))
+ (push content content-strs)))))
+ (error
+ (goto-char (match-beginning 0))))
+ (apply #'concat (nreverse content-strs))))
+
+(cl-defmethod gptel--parse-response ((_backend gptel-openai) response _info)
+ (map-nested-elt response '(:choices 0 :message :content)))
+
+(cl-defmethod gptel--request-data ((_backend gptel-openai) prompts)
+ "JSON encode PROMPTS for sending to ChatGPT."
+ (let ((prompts-plist
+ `(:model ,gptel-model
+ :messages [,@prompts]
+ :stream ,(or (and gptel-stream gptel-use-curl
+ (gptel-backend-stream gptel-backend))
+ :json-false))))
+ (when gptel-temperature
+ (plist-put prompts-plist :temperature gptel-temperature))
+ (when gptel-max-tokens
+ (plist-put prompts-plist :max_tokens gptel-max-tokens))
+ prompts-plist))
+
+(cl-defmethod gptel--parse-buffer ((_backend gptel-openai) &optional max-entries)
+ (let ((prompts) (prop))
+ (while (and
+ (or (not max-entries) (>= max-entries 0))
+ (setq prop (text-property-search-backward
+ 'gptel 'response
+ (when (get-char-property (max (point-min) (1- (point)))
+ 'gptel)
+ t))))
+ (push (list :role (if (prop-match-value prop) "assistant" "user")
+ :content
+ (string-trim
+ (buffer-substring-no-properties (prop-match-beginning prop)
+ (prop-match-end prop))
+ "[*# \t\n\r]+"))
+ prompts)
+ (and max-entries (cl-decf max-entries)))
+ (cons (list :role "system"
+ :content gptel--system-message)
+ prompts)))
+
+;;;###autoload
+(cl-defun gptel-make-openai
+ (name &key header key models stream
+ (host "api.openai.com")
+ (protocol "https")
+ (endpoint "/v1/chat/completions"))
+ "Register a ChatGPT backend for gptel with NAME.
+
+Keyword arguments:
+
+HOST (optional) is the API host, typically \"api.openai.com\".
+
+MODELS is a list of available model names.
+
+STREAM is a boolean to toggle streaming responses, defaults to
+false.
+
+PROTOCOL (optional) specifies the protocol, https by default.
+
+ENDPOINT (optional) is the API endpoint for completions, defaults to
+\"/v1/chat/completions\".
+
+HEADER (optional) is for additional headers to send with each
+request. It should be an alist or a function that retuns an
+alist, like:
+((\"Content-Type\" . \"application/json\"))
+
+KEY (optional) is a variable whose value is the API key, or
+function that returns the key."
+ (let ((backend (gptel--make-openai
+ :name name
+ :host host
+ :header header
+ :key key
+ :models models
+ :protocol protocol
+ :endpoint endpoint
+ :stream stream
+ :url (if protocol
+ (concat protocol "://" host endpoint)
+ (concat host endpoint)))))
+ (prog1 backend
+ (setf (alist-get name gptel--known-backends
+ nil nil #'equal)
+ backend))))
+
+;;; Azure
+;;;###autoload
+(cl-defun gptel-make-azure
+ (name &key host
+ (protocol "https")
+ (header (lambda () `(("api-key" . ,(gptel--get-api-key)))))
+ (key 'gptel-api-key)
+ models stream endpoint)
+ "Register an Azure backend for gptel with NAME.
+
+Keyword arguments:
+
+HOST is the API host.
+
+MODELS is a list of available model names.
+
+STREAM is a boolean to toggle streaming responses, defaults to
+false.
+
+PROTOCOL (optional) specifies the protocol, https by default.
+
+ENDPOINT is the API endpoint for completions.
+
+HEADER (optional) is for additional headers to send with each
+request. It should be an alist or a function that retuns an
+alist, like:
+((\"Content-Type\" . \"application/json\"))
+
+KEY (optional) is a variable whose value is the API key, or
+function that returns the key."
+ (let ((backend (gptel--make-openai
+ :name name
+ :host host
+ :header header
+ :key key
+ :models models
+ :protocol protocol
+ :endpoint endpoint
+ :stream stream
+ :url (if protocol
+ (concat protocol "://" host endpoint)
+ (concat host endpoint)))))
+ (prog1 backend
+ (setf (alist-get name gptel--known-backends
+ nil nil #'equal)
+ backend))))
+
+;; GPT4All
+;;;###autoload
+(defalias 'gptel-make-gpt4all 'gptel-make-openai
+ "Register a GPT4All backend for gptel with NAME.
+
+Keyword arguments:
+
+HOST is where GPT4All runs (with port), typically localhost:8491
+
+MODELS is a list of available model names.
+
+STREAM is a boolean to toggle streaming responses, defaults to
+false.
+
+PROTOCOL specifies the protocol, https by default.
+
+ENDPOINT (optional) is the API endpoint for completions, defaults to
+\"/api/v1/completions\"
+
+HEADER (optional) is for additional headers to send with each
+request. It should be an alist or a function that retuns an
+alist, like:
+((\"Content-Type\" . \"application/json\"))
+
+KEY (optional) is a variable whose value is the API key, or
+function that returns the key. This is typically not required for
+local models like GPT4All.")
+
+(provide 'gptel-openai)
+;;; gptel-backends.el ends here
diff --git a/gptel-transient.el b/gptel-transient.el
index fb81b1f..00e58e8 100644
--- a/gptel-transient.el
+++ b/gptel-transient.el
@@ -116,23 +116,24 @@ which see."
gptel--system-message (max (- (window-width) 14) 20) nil nil t)))
("h" "Set directives for chat" gptel-system-prompt :transient t)]
[["Session Parameters"
+ (gptel--infix-provider)
+ ;; (gptel--infix-model)
(gptel--infix-max-tokens)
(gptel--infix-num-messages-to-send)
- (gptel--infix-temperature)
- (gptel--infix-model)]
+ (gptel--infix-temperature)]
["Prompt:"
- ("-r" "From minibuffer instead" "-r")
- ("-i" "Replace/Delete prompt" "-i")
+ ("p" "From minibuffer instead" "p")
+ ("i" "Replace/Delete prompt" "i")
"Response to:"
- ("-m" "Minibuffer instead" "-m")
- ("-n" "New session" "-n"
+ ("m" "Minibuffer instead" "m")
+ ("n" "New session" "n"
:class transient-option
:prompt "Name for new session: "
:reader
(lambda (prompt _ history)
(read-string
prompt (generate-new-buffer-name "*ChatGPT*") history)))
- ("-e" "Existing session" "-e"
+ ("e" "Existing session" "e"
:class transient-option
:prompt "Existing session: "
:reader
@@ -142,7 +143,7 @@ which see."
(lambda (buf) (and (buffer-local-value 'gptel-mode (get-buffer buf))
(not (equal (current-buffer) buf))))
t nil history)))
- ("-k" "Kill-ring" "-k")]
+ ("k" "Kill-ring" "k")]
[:description gptel--refactor-or-rewrite
:if use-region-p
("r"
@@ -245,7 +246,7 @@ include."
:description "Number of past messages to send"
:class 'transient-lisp-variable
:variable 'gptel--num-messages-to-send
- :key "n"
+ :key "-n"
:prompt "Number of past messages to include for context (leave empty for all): "
:reader 'gptel--transient-read-variable)
@@ -262,16 +263,61 @@ will get progressively longer!"
:description "Response length (tokens)"
:class 'transient-lisp-variable
:variable 'gptel-max-tokens
- :key "<"
+ :key "-c"
:prompt "Response length in tokens (leave empty: default, 80-200: short, 200-500: long): "
:reader 'gptel--transient-read-variable)
+(defclass gptel-provider-variable (transient-lisp-variable)
+ ((model :initarg :model)
+ (model-value :initarg :model-value)
+ (always-read :initform t)
+ (set-value :initarg :set-value :initform #'set))
+ "Class used for gptel-backends.")
+
+(cl-defmethod transient-format-value ((obj gptel-provider-variable))
+ (propertize (concat (gptel-backend-name (oref obj value)) ":"
+ (buffer-local-value (oref obj model) transient--original-buffer))
+ 'face 'transient-value))
+
+(cl-defmethod transient-infix-set ((obj gptel-provider-variable) value)
+ (pcase-let ((`(,backend-value ,model-value) value))
+ (funcall (oref obj set-value)
+ (oref obj variable)
+ (oset obj value backend-value))
+ (funcall (oref obj set-value)
+ (oref obj model)
+ (oset obj model-value model-value))))
+
+(transient-define-infix gptel--infix-provider ()
+ "AI Provider for Chat."
+ :description "GPT Model: "
+ :class 'gptel-provider-variable
+ :prompt "Model provider: "
+ :variable 'gptel-backend
+ :model 'gptel-model
+ :key "-m"
+ :reader (lambda (prompt &rest _)
+ (let* ((backend-name
+ (if (<= (length gptel--known-backends) 1)
+ (caar gptel--known-backends)
+ (completing-read
+ prompt
+ (mapcar #'car gptel--known-backends))))
+ (backend (alist-get backend-name gptel--known-backends
+ nil nil #'equal))
+ (backend-models (gptel-backend-models backend))
+ (model-name (if (= (length backend-models) 1)
+ (car backend-models)
+ (completing-read
+ "Model: " backend-models))))
+ (list backend model-name))))
+
(transient-define-infix gptel--infix-model ()
"AI Model for Chat."
:description "GPT Model: "
:class 'transient-lisp-variable
:variable 'gptel-model
- :key "m"
+ :key "-m"
:choices '("gpt-3.5-turbo" "gpt-3.5-turbo-16k" "gpt-4" "gpt-4-32k")
:reader (lambda (prompt &rest _)
(completing-read
@@ -283,7 +329,7 @@ will get progressively longer!"
:description "Randomness (0 - 2.0)"
:class 'transient-lisp-variable
:variable 'gptel-temperature
- :key "t"
+ :key "-t"
:prompt "Set temperature (0.0-2.0, leave empty for default): "
:reader 'gptel--transient-read-variable)
@@ -313,42 +359,43 @@ will get progressively longer!"
:description "Send prompt"
(interactive (list (transient-args transient-current-command)))
(let ((stream gptel-stream)
- (in-place (and (member "-i" args) t))
+ (in-place (and (member "i" args) t))
(output-to-other-buffer-p)
+ (backend-name (gptel-backend-name gptel-backend))
(buffer) (position)
(callback) (gptel-buffer-name)
(prompt
- (and (member "-r" args)
+ (and (member "p" args)
(read-string
- "Ask ChatGPT: "
+ (format "Ask %s: " (gptel-backend-name gptel-backend))
(apply #'buffer-substring-no-properties
(if (use-region-p)
(list (region-beginning) (region-end))
(list (line-beginning-position) (line-end-position))))))))
(cond
- ((member "-m" args)
+ ((member "m" args)
(setq stream nil)
(setq callback
(lambda (resp info)
(if resp
- (message "ChatGPT response: %s" resp)
- (message "ChatGPT response error: %s" (plist-get info :status))))))
- ((member "-k" args)
+ (message "%s response: %s" backend-name resp)
+ (message "%s response error: %s" backend-name (plist-get info :status))))))
+ ((member "k" args)
(setq stream nil)
(setq callback
(lambda (resp info)
(if (not resp)
- (message "ChatGPT response error: %s" (plist-get info :status))
+ (message "%s response error: %s" backend-name (plist-get info :status))
(kill-new resp)
- (message "ChatGPT response: copied to kill-ring.")))))
+ (message "%s response: copied to kill-ring." backend-name)))))
((setq gptel-buffer-name
- (cl-some (lambda (s) (and (string-prefix-p "-n" s)
- (substring s 2)))
+ (cl-some (lambda (s) (and (string-prefix-p "n" s)
+ (substring s 1)))
args))
(setq buffer
(gptel gptel-buffer-name
(condition-case nil
- (gptel--api-key)
+ (gptel--get-api-key)
((error user-error)
(setq gptel-api-key
(read-passwd "OpenAI API key: "))))
@@ -370,7 +417,7 @@ will get progressively longer!"
(setq position (point)))
(setq output-to-other-buffer-p t))
((setq gptel-buffer-name
- (cl-some (lambda (s) (and (string-prefix-p "-e" s)
+ (cl-some (lambda (s) (and (string-prefix-p "e" s)
(substring s 2)))
args))
(setq buffer (get-buffer gptel-buffer-name))
diff --git a/gptel.el b/gptel.el
index a7870ee..f29a7ab 100644
--- a/gptel.el
+++ b/gptel.el
@@ -76,18 +76,20 @@
(require 'json)
(require 'map)
(require 'text-property-search)
+(require 'gptel-openai)
(defgroup gptel nil
"Interact with ChatGPT from anywhere in Emacs."
:group 'hypermedia)
-(defcustom gptel-host "api.openai.com"
- "The API host queried by gptel."
- :group 'gptel
- :type 'string)
-
-(defvar gptel-protocol "https"
- "Protocol used to query `gptel-host'.")
+;; (defcustom gptel-host "api.openai.com"
+;; "The API host queried by gptel."
+;; :group 'gptel
+;; :type 'string)
+(make-obsolete-variable
+ 'gptel-host
+ "Use `gptel-make-openai' instead."
+ "0.5.0")
(defcustom gptel-proxy ""
"Path to a proxy to use for gptel interactions.
@@ -257,7 +259,7 @@ will get progressively longer!"
(defcustom gptel-model "gpt-3.5-turbo"
"GPT Model for chat.
-The current options are
+The current options for ChatGPT are
- \"gpt-3.5-turbo\"
- \"gpt-3.5-turbo-16k\"
- \"gpt-4\" (experimental)
@@ -287,20 +289,46 @@ To set the temperature for a chat session interactively call
:group 'gptel
:type 'number)
+(defvar gptel--known-backends nil
+ "Alist of LLM backends known to gptel.
+
+This is an alist mapping user-provided names to backend structs,
+see `gptel-backend'.
+
+You can have more than one backend pointing to the same resource
+with differing settings.")
+
+(defvar gptel--openai
+ (gptel-make-openai
+ "ChatGPT"
+ :header (lambda () `(("Authorization" . ,(concat "Bearer " (gptel--get-api-key)))))
+ :key #'gptel--get-api-key
+ :stream t
+ :models '("gpt-3.5-turbo" "gpt-3.5-turbo-16k" "gpt-4" "gpt-4-32k")))
+
+(defvar-local gptel-backend gptel--openai)
+
(defvar-local gptel--bounds nil)
(put 'gptel--bounds 'safe-local-variable #'gptel--always)
(defvar-local gptel--num-messages-to-send nil)
(put 'gptel--num-messages-to-send 'safe-local-variable #'gptel--always)
-(defvar gptel--debug nil)
+
+(defvar gptel--debug nil
+ "Enable printing debug messages.
+
+Also shows the response buffer when making requests.")
(defun gptel-api-key-from-auth-source (&optional host user)
"Lookup api key in the auth source.
-By default, `gptel-host' is used as HOST and \"apikey\" as USER."
- (if-let ((secret (plist-get (car (auth-source-search
- :host (or host gptel-host)
- :user (or user "apikey")
- :require '(:secret)))
+By default, the LLM host for the active backend is used as HOST,
+and \"apikey\" as USER."
+ (if-let ((secret
+ (plist-get
+ (car (auth-source-search
+ :host (or host (gptel-backend-host gptel-backend))
+ :user (or user "apikey")
+ :require '(:secret)))
:secret)))
(if (functionp secret)
(encode-coding-string (funcall secret) 'utf-8)
@@ -308,7 +336,7 @@ By default, `gptel-host' is used as HOST and \"apikey\" as USER."
(user-error "No `gptel-api-key' found in the auth source")))
;; FIXME Should we utf-8 encode the api-key here?
-(defun gptel--api-key ()
+(defun gptel--get-api-key ()
"Get api key from `gptel-api-key'."
(pcase gptel-api-key
((pred stringp) gptel-api-key)
@@ -336,7 +364,8 @@ Currently saving and restoring state is implemented only for
(progn
(when-let ((bounds (org-entry-get (point-min) "GPTEL_BOUNDS")))
(mapc (pcase-lambda (`(,beg . ,end))
- (put-text-property beg end 'gptel 'response))
+ (add-text-properties
+ beg end '(gptel response rear-nonsticky t)))
(read bounds))
(message "gptel chat restored."))
(when-let ((model (org-entry-get (point-min) "GPTEL_MODEL")))
@@ -431,8 +460,8 @@ opening the file."
(gptel--restore-state)
(setq gptel--old-header-line header-line-format
header-line-format
- (list (concat (propertize " " 'display '(space :align-to 0))
- (format "%s" (buffer-name)))
+ (list '(:eval (concat (propertize " " 'display '(space :align-to 0))
+ (format "%s" (gptel-backend-name gptel-backend))))
(propertize " Ready" 'face 'success)
'(:eval
(let* ((l1 (length gptel-model))
@@ -468,8 +497,8 @@ opening the file."
(cl-defun gptel-request
(&optional prompt &key callback
(buffer (current-buffer))
- position context (stream nil)
- (in-place nil)
+ position context
+ (stream nil) (in-place nil)
(system gptel--system-message))
"Request a response from ChatGPT for PROMPT.
@@ -581,7 +610,7 @@ instead."
(interactive "P")
(if (and arg (require 'gptel-transient nil t))
(call-interactively #'gptel-menu)
- (message "Querying ChatGPT...")
+ (message "Querying %s..." (gptel-backend-name gptel-backend))
(let* ((response-pt
(if (use-region-p)
(set-marker (make-marker) (region-end))
@@ -698,38 +727,24 @@ there."
(goto-char (point-max))))
(t (goto-char (or prompt-end (point-max)))))
(let ((max-entries (and gptel--num-messages-to-send
- (* 2 gptel--num-messages-to-send)))
- (prop) (prompts))
- (while (and
- (or (not max-entries) (>= max-entries 0))
- (setq prop (text-property-search-backward
- 'gptel 'response
- (when (get-char-property (max (point-min) (1- (point)))
- 'gptel)
- t))))
- (push (list :role (if (prop-match-value prop) "assistant" "user")
- :content
- (string-trim
- (buffer-substring-no-properties (prop-match-beginning prop)
- (prop-match-end prop))
- "[*# \t\n\r]+"))
- prompts)
- (and max-entries (cl-decf max-entries)))
- (cons (list :role "system"
- :content gptel--system-message)
- prompts)))))
+ (* 2 gptel--num-messages-to-send))))
+ (gptel--parse-buffer gptel-backend max-entries)))))
-(defun gptel--request-data (prompts)
- "JSON encode PROMPTS for sending to ChatGPT."
- (let ((prompts-plist
- `(:model ,gptel-model
- :messages [,@prompts]
- :stream ,(or (and gptel-stream gptel-use-curl) :json-false))))
- (when gptel-temperature
- (plist-put prompts-plist :temperature gptel-temperature))
- (when gptel-max-tokens
- (plist-put prompts-plist :max_tokens gptel-max-tokens))
- prompts-plist))
+(cl-defgeneric gptel--parse-buffer (backend max-entries)
+ "Parse the current buffer backwards from point and return a list
+of prompts.
+
+BACKEND is the LLM backend in use.
+
+MAX-ENTRIES is the number of queries/responses to include for
+contexbt.")
+
+(cl-defgeneric gptel--request-data (backend prompts)
+ "Generate a plist of all data for an LLM query.
+
+BACKEND is the LLM backend in use.
+
+PROMPTS is the plist of previous user queries and LLM responses.")
;; TODO: Use `run-hook-wrapped' with an accumulator instead to handle
;; buffer-local hooks, etc.
@@ -773,13 +788,17 @@ the response is inserted into the current buffer after point."
(message-log-max nil)
(url-request-method "POST")
(url-request-extra-headers
- `(("Content-Type" . "application/json")
- ("Authorization" . ,(concat "Bearer " (gptel--api-key)))))
+ (append '(("Content-Type" . "application/json"))
+ (when-let ((backend-header (gptel-backend-header gptel-backend)))
+ (if (functionp backend-header)
+ (funcall backend-header)
+ backend-header))))
(url-request-data
(encode-coding-string
- (json-encode (gptel--request-data (plist-get info :prompt)))
+ (json-encode (gptel--request-data
+ gptel-backend (plist-get info :prompt)))
'utf-8)))
- (url-retrieve (format "%s://%s/v1/chat/completions" gptel-protocol gptel-host)
+ (url-retrieve (gptel-backend-url gptel-backend)
(lambda (_)
(pcase-let ((`(,response ,http-msg ,error)
(gptel--url-parse-response (current-buffer))))
@@ -790,6 +809,16 @@ the response is inserted into the current buffer after point."
(kill-buffer)))
nil t nil)))
+(cl-defgeneric gptel--parse-response (backend response proc-info)
+ "Response extractor for LLM requests.
+
+BACKEND is the LLM backend in use.
+
+RESPONSE is the parsed JSON of the response, as a plist.
+
+PROC-INFO is a plist with process information and other context.
+See `gptel-curl--get-response' for its contents.")
+
(defun gptel--url-parse-response (response-buffer)
"Parse response in RESPONSE-BUFFER."
(when (buffer-live-p response-buffer)
@@ -809,7 +838,8 @@ the response is inserted into the current buffer after point."
(json-readtable-error 'json-read-error))))))
(cond
((string-match-p "200 OK" http-msg)
- (list (string-trim (map-nested-elt response '(:choices 0 :message :content)))
+ (list (string-trim (gptel--parse-response gptel-backend response
+ '(:buffer response-buffer)))
http-msg))
((plist-get response :error)
(let* ((error-plist (plist-get response :error))
@@ -837,7 +867,7 @@ buffer created or switched to."
(read-string "Session name: " (generate-new-buffer-name gptel-default-session))
gptel-default-session)
(condition-case nil
- (gptel--api-key)
+ (gptel--get-api-key)
((error user-error)
(setq gptel-api-key
(read-passwd "OpenAI API key: "))))