config

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

gptel-openai.el (16388B)


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