config

Personal configuration.
git clone git://code.dwrz.net/config
Log | Files | Refs

gptel-openai.el (16333B)


      1 ;;; gptel-openai.el ---  ChatGPT suppport for gptel  -*- lexical-binding: t; -*-
      2 
      3 ;; Copyright (C) 2023  Karthik Chikmagalur
      4 
      5 ;; Author: Karthik Chikmagalur <karthikchikmagalur@gmail.com>
      6 
      7 ;; This program is free software; you can redistribute it and/or modify
      8 ;; it under the terms of the GNU General Public License as published by
      9 ;; the Free Software Foundation, either version 3 of the License, or
     10 ;; (at your option) any later version.
     11 
     12 ;; This program is distributed in the hope that it will be useful,
     13 ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
     14 ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     15 ;; GNU General Public License for more details.
     16 
     17 ;; You should have received a copy of the GNU General Public License
     18 ;; along with this program.  If not, see <https://www.gnu.org/licenses/>.
     19 
     20 ;;; Commentary:
     21 
     22 ;; This file adds support for the ChatGPT API to gptel
     23 
     24 ;;; Code:
     25 (require 'cl-generic)
     26 (eval-when-compile
     27   (require 'cl-lib))
     28 (require 'map)
     29 
     30 (defvar gptel-model)
     31 (defvar gptel-stream)
     32 (defvar gptel-use-curl)
     33 (defvar gptel-backend)
     34 (defvar gptel-temperature)
     35 (defvar gptel-max-tokens)
     36 (defvar gptel--system-message)
     37 (defvar json-object-type)
     38 (defvar gptel-mode)
     39 (defvar gptel-track-response)
     40 (defvar gptel-track-media)
     41 (declare-function gptel-context--collect-media "gptel-context")
     42 (declare-function gptel--base64-encode "gptel")
     43 (declare-function gptel--trim-prefixes "gptel")
     44 (declare-function gptel--parse-media-links "gptel")
     45 (declare-function gptel--model-capable-p "gptel")
     46 (declare-function gptel--model-name "gptel")
     47 (declare-function gptel--get-api-key "gptel")
     48 (declare-function prop-match-value "text-property-search")
     49 (declare-function text-property-search-backward "text-property-search")
     50 (declare-function json-read "json")
     51 (declare-function gptel-prompt-prefix-string "gptel")
     52 (declare-function gptel-response-prefix-string "gptel")
     53 (declare-function gptel--merge-plists "gptel")
     54 (declare-function gptel-context--wrap "gptel-context")
     55 
     56 (defmacro gptel--json-read ()
     57   (if (fboundp 'json-parse-buffer)
     58       `(json-parse-buffer
     59         :object-type 'plist
     60         :null-object nil
     61         :false-object :json-false)
     62     (require 'json)
     63     (defvar json-object-type)
     64     (declare-function json-read "json" ())
     65     `(let ((json-object-type 'plist))
     66       (json-read))))
     67 
     68 (defmacro gptel--json-encode (object)
     69   (if (fboundp 'json-serialize)
     70       `(json-serialize ,object
     71         :null-object nil
     72         :false-object :json-false)
     73     (require 'json)
     74     (defvar json-false)
     75     (declare-function json-encode "json" (object))
     76     `(let ((json-false :json-false))
     77       (json-encode ,object))))
     78 
     79 (defun gptel--process-models (models)
     80   "Convert items in MODELS to symbols with appropriate properties."
     81   (let ((models-processed))
     82     (dolist (model models)
     83       (cl-etypecase model
     84         (string (push (intern model) models-processed))
     85         (symbol (push model models-processed))
     86         (cons
     87          (cl-destructuring-bind (name . props) model
     88            (setf (symbol-plist name)
     89                  ;; MAYBE: Merging existing symbol plists is safer, but makes it
     90                  ;; difficult to reset a symbol plist, since removing keys from
     91                  ;; it (as opposed to setting them to nil) is more work.
     92                  ;;
     93                  ;; (map-merge 'plist (symbol-plist name) props)
     94                  props)
     95            (push name models-processed)))))
     96     (nreverse models-processed)))
     97 
     98 ;;; Common backend struct for LLM support
     99 (defvar gptel--known-backends nil
    100   "Alist of LLM backends known to gptel.
    101 
    102 This is an alist mapping user-provided names to backend structs,
    103 see `gptel-backend'.
    104 
    105 You can have more than one backend pointing to the same resource
    106 with differing settings.")
    107 
    108 (cl-defstruct
    109     (gptel-backend (:constructor gptel--make-backend)
    110                    (:copier gptel--copy-backend))
    111   name host header protocol stream
    112   endpoint key models url request-params
    113   curl-args)
    114 
    115 ;;; OpenAI (ChatGPT)
    116 (cl-defstruct (gptel-openai (:constructor gptel--make-openai)
    117                             (:copier nil)
    118                             (:include gptel-backend)))
    119 
    120 (cl-defmethod gptel-curl--parse-stream ((_backend gptel-openai) _info)
    121   (let* ((content-strs))
    122     (condition-case nil
    123         (while (re-search-forward "^data:" nil t)
    124           (save-match-data
    125             (unless (looking-at " *\\[DONE\\]")
    126               (when-let* ((response (gptel--json-read))
    127                           (delta (map-nested-elt
    128                                   response '(:choices 0 :delta)))
    129                           (content (plist-get delta :content)))
    130                 (push content content-strs)))))
    131       (error
    132        (goto-char (match-beginning 0))))
    133     (apply #'concat (nreverse content-strs))))
    134 
    135 (cl-defmethod gptel--parse-response ((_backend gptel-openai) response _info)
    136   (map-nested-elt response '(:choices 0 :message :content)))
    137 
    138 (cl-defmethod gptel--request-data ((_backend gptel-openai) prompts)
    139   "JSON encode PROMPTS for sending to ChatGPT."
    140   (let ((prompts-plist
    141          `(:model ,(gptel--model-name gptel-model)
    142            :messages [,@prompts]
    143            :stream ,(or (and gptel-stream gptel-use-curl
    144                          (gptel-backend-stream gptel-backend))
    145                      :json-false))))
    146     (when gptel-temperature
    147       (plist-put prompts-plist :temperature gptel-temperature))
    148     (when gptel-max-tokens
    149       (plist-put prompts-plist :max_completion_tokens gptel-max-tokens))
    150     ;; Merge request params with model and backend params.
    151     (gptel--merge-plists
    152      prompts-plist
    153      (gptel-backend-request-params gptel-backend)
    154      (gptel--model-request-params  gptel-model))))
    155 
    156 (cl-defmethod gptel--parse-buffer ((_backend gptel-openai) &optional max-entries)
    157   (let ((prompts) (prop)
    158         (include-media (and gptel-track-media
    159                             (or (gptel--model-capable-p 'media)
    160                                 (gptel--model-capable-p 'url)))))
    161     (if (or gptel-mode gptel-track-response)
    162         (while (and
    163                 (or (not max-entries) (>= max-entries 0))
    164                 (setq prop (text-property-search-backward
    165                             'gptel 'response
    166                             (when (get-char-property (max (point-min) (1- (point)))
    167                                                      'gptel)
    168                               t))))
    169           (if (prop-match-value prop)   ;assistant role
    170               (push (list :role "assistant"
    171                           :content
    172                           (buffer-substring-no-properties (prop-match-beginning prop)
    173                                                           (prop-match-end prop)))
    174                     prompts)
    175             (if include-media
    176                 (push (list :role "user"
    177                             :content
    178                             (gptel--openai-parse-multipart
    179                              (gptel--parse-media-links
    180                               major-mode (prop-match-beginning prop) (prop-match-end prop))))
    181                       prompts)
    182               (push (list :role "user"
    183                           :content
    184                           (gptel--trim-prefixes
    185                            (buffer-substring-no-properties (prop-match-beginning prop)
    186                                                            (prop-match-end prop))))
    187                     prompts)))
    188           (and max-entries (cl-decf max-entries)))
    189       (push (list :role "user"
    190                   :content
    191                   (gptel--trim-prefixes (buffer-substring-no-properties (point-min) (point-max))))
    192             prompts))
    193     (if (and (not (gptel--model-capable-p 'nosystem))
    194              gptel--system-message)
    195         (cons (list :role "system"
    196                     :content gptel--system-message)
    197               prompts)
    198       prompts)))
    199 
    200 ;; TODO This could be a generic function
    201 (defun gptel--openai-parse-multipart (parts)
    202   "Convert a multipart prompt PARTS to the OpenAI API format.
    203 
    204 The input is an alist of the form
    205  ((:text \"some text\")
    206   (:media \"/path/to/media.png\" :mime \"image/png\")
    207   (:text \"More text\")).
    208 
    209 The output is a vector of entries in a backend-appropriate
    210 format."
    211   (cl-loop
    212    for part in parts
    213    for n upfrom 1
    214    with last = (length parts)
    215    for text = (plist-get part :text)
    216    for media = (plist-get part :media)
    217    if text do
    218    (and (or (= n 1) (= n last)) (setq text (gptel--trim-prefixes text))) and
    219    unless (string-empty-p text)
    220    collect `(:type "text" :text ,text) into parts-array end
    221    else if media
    222    collect
    223    `(:type "image_url"
    224      :image_url (:url ,(concat "data:" (plist-get part :mime)
    225                         ";base64," (gptel--base64-encode media))))
    226    into parts-array end and
    227    if (plist-get part :url)
    228    collect
    229    `(:type "image_url"
    230      :image_url (:url ,(plist-get part :url)))
    231    into parts-array
    232    finally return (vconcat parts-array)))
    233 
    234 ;; TODO: Does this need to be a generic function?
    235 (cl-defmethod gptel--wrap-user-prompt ((_backend gptel-openai) prompts
    236                                        &optional inject-media)
    237   "Wrap the last user prompt in PROMPTS with the context string.
    238 
    239 If INJECT-MEDIA is non-nil wrap it with base64-encoded media
    240 files in the context."
    241   (if inject-media
    242       ;; Wrap the first user prompt with included media files/contexts
    243       (when-let ((media-list (gptel-context--collect-media)))
    244         (cl-callf (lambda (current)
    245                     (vconcat
    246                      (gptel--openai-parse-multipart media-list)
    247                      (cl-typecase current
    248                        (string `((:type "text" :text ,current)))
    249                        (vector current)
    250                        (t current))))
    251             (plist-get (cadr prompts) :content)))
    252     ;; Wrap the last user prompt with included text contexts
    253     (cl-callf (lambda (current)
    254                 (cl-etypecase current
    255                   (string (gptel-context--wrap current))
    256                   (vector (if-let ((wrapped (gptel-context--wrap nil)))
    257                               (vconcat `((:type "text" :text ,wrapped))
    258                                        current)
    259                             current))))
    260         (plist-get (car (last prompts)) :content))))
    261 
    262 ;;;###autoload
    263 (cl-defun gptel-make-openai
    264     (name &key curl-args models stream key request-params
    265           (header
    266            (lambda () (when-let (key (gptel--get-api-key))
    267                    `(("Authorization" . ,(concat "Bearer " key))))))
    268           (host "api.openai.com")
    269           (protocol "https")
    270           (endpoint "/v1/chat/completions"))
    271   "Register an OpenAI API-compatible backend for gptel with NAME.
    272 
    273 Keyword arguments:
    274 
    275 CURL-ARGS (optional) is a list of additional Curl arguments.
    276 
    277 HOST (optional) is the API host, typically \"api.openai.com\".
    278 
    279 MODELS is a list of available model names, as symbols.
    280 Additionally, you can specify supported LLM capabilities like
    281 vision or tool-use by appending a plist to the model with more
    282 information, in the form
    283 
    284  (model-name . plist)
    285 
    286 For a list of currently recognized plist keys, see
    287 `gptel--openai-models'. An example of a model specification
    288 including both kinds of specs:
    289 
    290 :models
    291 \\='(gpt-3.5-turbo                         ;Simple specs
    292   gpt-4-turbo
    293   (gpt-4o-mini                          ;Full spec
    294    :description
    295    \"Affordable and intelligent small model for lightweight tasks\"
    296    :capabilities (media tool json url)
    297    :mime-types
    298    (\"image/jpeg\" \"image/png\" \"image/gif\" \"image/webp\")))
    299 
    300 STREAM is a boolean to toggle streaming responses, defaults to
    301 false.
    302 
    303 PROTOCOL (optional) specifies the protocol, https by default.
    304 
    305 ENDPOINT (optional) is the API endpoint for completions, defaults to
    306 \"/v1/chat/completions\".
    307 
    308 HEADER (optional) is for additional headers to send with each
    309 request.  It should be an alist or a function that retuns an
    310 alist, like:
    311  ((\"Content-Type\" . \"application/json\"))
    312 
    313 KEY (optional) is a variable whose value is the API key, or
    314 function that returns the key.
    315 
    316 REQUEST-PARAMS (optional) is a plist of additional HTTP request
    317 parameters (as plist keys) and values supported by the API.  Use
    318 these to set parameters that gptel does not provide user options
    319 for."
    320   (declare (indent 1))
    321   (let ((backend (gptel--make-openai
    322                   :curl-args curl-args
    323                   :name name
    324                   :host host
    325                   :header header
    326                   :key key
    327                   :models (gptel--process-models models)
    328                   :protocol protocol
    329                   :endpoint endpoint
    330                   :stream stream
    331                   :request-params request-params
    332                   :url (if protocol
    333                            (concat protocol "://" host endpoint)
    334                          (concat host endpoint)))))
    335     (prog1 backend
    336       (setf (alist-get name gptel--known-backends
    337                        nil nil #'equal)
    338                   backend))))
    339 
    340 ;;; Azure
    341 ;;;###autoload
    342 (cl-defun gptel-make-azure
    343     (name &key curl-args host
    344           (protocol "https")
    345           (header (lambda () `(("api-key" . ,(gptel--get-api-key)))))
    346           (key 'gptel-api-key)
    347           models stream endpoint request-params)
    348   "Register an Azure backend for gptel with NAME.
    349 
    350 Keyword arguments:
    351 
    352 CURL-ARGS (optional) is a list of additional Curl arguments.
    353 
    354 HOST is the API host.
    355 
    356 MODELS is a list of available model names, as symbols.
    357 
    358 STREAM is a boolean to toggle streaming responses, defaults to
    359 false.
    360 
    361 PROTOCOL (optional) specifies the protocol, https by default.
    362 
    363 ENDPOINT is the API endpoint for completions.
    364 
    365 HEADER (optional) is for additional headers to send with each
    366 request.  It should be an alist or a function that retuns an
    367 alist, like:
    368  ((\"Content-Type\" . \"application/json\"))
    369 
    370 KEY (optional) is a variable whose value is the API key, or
    371 function that returns the key.
    372 
    373 REQUEST-PARAMS (optional) is a plist of additional HTTP request
    374 parameters (as plist keys) and values supported by the API.  Use
    375 these to set parameters that gptel does not provide user options
    376 for.
    377 
    378 Example:
    379 -------
    380 
    381  (gptel-make-azure
    382   \"Azure-1\"
    383   :protocol \"https\"
    384   :host \"RESOURCE_NAME.openai.azure.com\"
    385   :endpoint
    386   \"/openai/deployments/DEPLOYMENT_NAME/completions?api-version=2023-05-15\"
    387   :stream t
    388   :models \\='(gpt-3.5-turbo gpt-4))"
    389   (declare (indent 1))
    390   (let ((backend (gptel--make-openai
    391                   :curl-args curl-args
    392                   :name name
    393                   :host host
    394                   :header header
    395                   :key key
    396                   :models (gptel--process-models models)
    397                   :protocol protocol
    398                   :endpoint endpoint
    399                   :stream stream
    400                   :request-params request-params
    401                   :url (if protocol
    402                            (concat protocol "://" host endpoint)
    403                          (concat host endpoint)))))
    404     (prog1 backend
    405       (setf (alist-get name gptel--known-backends
    406                        nil nil #'equal)
    407             backend))))
    408 
    409 ;; GPT4All
    410 ;;;###autoload
    411 (defalias 'gptel-make-gpt4all 'gptel-make-openai
    412   "Register a GPT4All backend for gptel with NAME.
    413 
    414 Keyword arguments:
    415 
    416 CURL-ARGS (optional) is a list of additional Curl arguments.
    417 
    418 HOST is where GPT4All runs (with port), typically localhost:4891
    419 
    420 MODELS is a list of available model names, as symbols.
    421 
    422 STREAM is a boolean to toggle streaming responses, defaults to
    423 false.
    424 
    425 PROTOCOL specifies the protocol, https by default.
    426 
    427 ENDPOINT (optional) is the API endpoint for completions, defaults to
    428 \"/api/v1/completions\"
    429 
    430 HEADER (optional) is for additional headers to send with each
    431 request. It should be an alist or a function that retuns an
    432 alist, like:
    433 ((\"Content-Type\" . \"application/json\"))
    434 
    435 KEY (optional) is a variable whose value is the API key, or
    436 function that returns the key. This is typically not required for
    437 local models like GPT4All.
    438 
    439 REQUEST-PARAMS (optional) is a plist of additional HTTP request
    440 parameters (as plist keys) and values supported by the API.  Use
    441 these to set parameters that gptel does not provide user options
    442 for.
    443 
    444 Example:
    445 -------
    446 
    447 (gptel-make-gpt4all
    448  \"GPT4All\"
    449  :protocol \"http\"
    450  :host \"localhost:4891\"
    451  :models \\='(mistral-7b-openorca.Q4_0.gguf))")
    452 
    453 (provide 'gptel-openai)
    454 ;;; gptel-openai.el ends here