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: "))))