config

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

gptel-openai.el (16860B)


      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   (when (and gptel--system-message
    142              (not (gptel--model-capable-p 'nosystem)))
    143     (push (list :role "system"
    144                 :content gptel--system-message)
    145           prompts))
    146   (let ((prompts-plist
    147          `(:model ,(gptel--model-name gptel-model)
    148            :messages [,@prompts]
    149            :stream ,(or (and gptel-stream gptel-use-curl
    150                          (gptel-backend-stream gptel-backend))
    151                      :json-false))))
    152     (when gptel-temperature
    153       (plist-put prompts-plist :temperature gptel-temperature))
    154     (when gptel-max-tokens
    155       ;; HACK: The OpenAI API has deprecated max_tokens, but we still need it
    156       ;; for OpenAI-compatible APIs like GPT4All (#485)
    157       (plist-put prompts-plist (if (memq gptel-model '(o1-preview o1-mini))
    158                                    :max_completion_tokens :max_tokens)
    159                  gptel-max-tokens))
    160     ;; Merge request params with model and backend params.
    161     (gptel--merge-plists
    162      prompts-plist
    163      (gptel-backend-request-params gptel-backend)
    164      (gptel--model-request-params  gptel-model))))
    165 
    166 (cl-defmethod gptel--parse-list ((_backend gptel-openai) prompt-list)
    167   (cl-loop for text in prompt-list
    168            for role = t then (not role)
    169            if text collect
    170            (list :role (if role "user" "assistant") :content text)))
    171 
    172 (cl-defmethod gptel--parse-buffer ((_backend gptel-openai) &optional max-entries)
    173   (let ((prompts) (prop)
    174         (include-media (and gptel-track-media
    175                             (or (gptel--model-capable-p 'media)
    176                                 (gptel--model-capable-p 'url)))))
    177     (if (or gptel-mode gptel-track-response)
    178         (while (and
    179                 (or (not max-entries) (>= max-entries 0))
    180                 (setq prop (text-property-search-backward
    181                             'gptel 'response
    182                             (when (get-char-property (max (point-min) (1- (point)))
    183                                                      'gptel)
    184                               t))))
    185           (if (prop-match-value prop)   ;assistant role
    186               (push (list :role "assistant"
    187                           :content
    188                           (buffer-substring-no-properties (prop-match-beginning prop)
    189                                                           (prop-match-end prop)))
    190                     prompts)
    191             (if include-media
    192                 (push (list :role "user"
    193                             :content
    194                             (gptel--openai-parse-multipart
    195                              (gptel--parse-media-links
    196                               major-mode (prop-match-beginning prop) (prop-match-end prop))))
    197                       prompts)
    198               (push (list :role "user"
    199                           :content
    200                           (gptel--trim-prefixes
    201                            (buffer-substring-no-properties (prop-match-beginning prop)
    202                                                            (prop-match-end prop))))
    203                     prompts)))
    204           (and max-entries (cl-decf max-entries)))
    205       (push (list :role "user"
    206                   :content
    207                   (gptel--trim-prefixes (buffer-substring-no-properties (point-min) (point-max))))
    208             prompts))
    209     prompts))
    210 
    211 ;; TODO This could be a generic function
    212 (defun gptel--openai-parse-multipart (parts)
    213   "Convert a multipart prompt PARTS to the OpenAI API format.
    214 
    215 The input is an alist of the form
    216  ((:text \"some text\")
    217   (:media \"/path/to/media.png\" :mime \"image/png\")
    218   (:text \"More text\")).
    219 
    220 The output is a vector of entries in a backend-appropriate
    221 format."
    222   (cl-loop
    223    for part in parts
    224    for n upfrom 1
    225    with last = (length parts)
    226    for text = (plist-get part :text)
    227    for media = (plist-get part :media)
    228    if text do
    229    (and (or (= n 1) (= n last)) (setq text (gptel--trim-prefixes text))) and
    230    unless (string-empty-p text)
    231    collect `(:type "text" :text ,text) into parts-array end
    232    else if media
    233    collect
    234    `(:type "image_url"
    235      :image_url (:url ,(concat "data:" (plist-get part :mime)
    236                         ";base64," (gptel--base64-encode media))))
    237    into parts-array end and
    238    if (plist-get part :url)
    239    collect
    240    `(:type "image_url"
    241      :image_url (:url ,(plist-get part :url)))
    242    into parts-array
    243    finally return (vconcat parts-array)))
    244 
    245 ;; TODO: Does this need to be a generic function?
    246 (cl-defmethod gptel--wrap-user-prompt ((_backend gptel-openai) prompts
    247                                        &optional inject-media)
    248   "Wrap the last user prompt in PROMPTS with the context string.
    249 
    250 If INJECT-MEDIA is non-nil wrap it with base64-encoded media
    251 files in the context."
    252   (if inject-media
    253       ;; Wrap the first user prompt with included media files/contexts
    254       (when-let ((media-list (gptel-context--collect-media)))
    255         (cl-callf (lambda (current)
    256                     (vconcat
    257                      (gptel--openai-parse-multipart media-list)
    258                      (cl-typecase current
    259                        (string `((:type "text" :text ,current)))
    260                        (vector current)
    261                        (t current))))
    262             (plist-get (cadr prompts) :content)))
    263     ;; Wrap the last user prompt with included text contexts
    264     (cl-callf (lambda (current)
    265                 (cl-etypecase current
    266                   (string (gptel-context--wrap current))
    267                   (vector (if-let ((wrapped (gptel-context--wrap nil)))
    268                               (vconcat `((:type "text" :text ,wrapped))
    269                                        current)
    270                             current))))
    271         (plist-get (car (last prompts)) :content))))
    272 
    273 ;;;###autoload
    274 (cl-defun gptel-make-openai
    275     (name &key curl-args models stream key request-params
    276           (header
    277            (lambda () (when-let (key (gptel--get-api-key))
    278                    `(("Authorization" . ,(concat "Bearer " key))))))
    279           (host "api.openai.com")
    280           (protocol "https")
    281           (endpoint "/v1/chat/completions"))
    282   "Register an OpenAI API-compatible backend for gptel with NAME.
    283 
    284 Keyword arguments:
    285 
    286 CURL-ARGS (optional) is a list of additional Curl arguments.
    287 
    288 HOST (optional) is the API host, typically \"api.openai.com\".
    289 
    290 MODELS is a list of available model names, as symbols.
    291 Additionally, you can specify supported LLM capabilities like
    292 vision or tool-use by appending a plist to the model with more
    293 information, in the form
    294 
    295  (model-name . plist)
    296 
    297 For a list of currently recognized plist keys, see
    298 `gptel--openai-models'. An example of a model specification
    299 including both kinds of specs:
    300 
    301 :models
    302 \\='(gpt-3.5-turbo                         ;Simple specs
    303   gpt-4-turbo
    304   (gpt-4o-mini                          ;Full spec
    305    :description
    306    \"Affordable and intelligent small model for lightweight tasks\"
    307    :capabilities (media tool json url)
    308    :mime-types
    309    (\"image/jpeg\" \"image/png\" \"image/gif\" \"image/webp\")))
    310 
    311 STREAM is a boolean to toggle streaming responses, defaults to
    312 false.
    313 
    314 PROTOCOL (optional) specifies the protocol, https by default.
    315 
    316 ENDPOINT (optional) is the API endpoint for completions, defaults to
    317 \"/v1/chat/completions\".
    318 
    319 HEADER (optional) is for additional headers to send with each
    320 request.  It should be an alist or a function that retuns an
    321 alist, like:
    322  ((\"Content-Type\" . \"application/json\"))
    323 
    324 KEY (optional) is a variable whose value is the API key, or
    325 function that returns the key.
    326 
    327 REQUEST-PARAMS (optional) is a plist of additional HTTP request
    328 parameters (as plist keys) and values supported by the API.  Use
    329 these to set parameters that gptel does not provide user options
    330 for."
    331   (declare (indent 1))
    332   (let ((backend (gptel--make-openai
    333                   :curl-args curl-args
    334                   :name name
    335                   :host host
    336                   :header header
    337                   :key key
    338                   :models (gptel--process-models models)
    339                   :protocol protocol
    340                   :endpoint endpoint
    341                   :stream stream
    342                   :request-params request-params
    343                   :url (if protocol
    344                            (concat protocol "://" host endpoint)
    345                          (concat host endpoint)))))
    346     (prog1 backend
    347       (setf (alist-get name gptel--known-backends
    348                        nil nil #'equal)
    349                   backend))))
    350 
    351 ;;; Azure
    352 ;;;###autoload
    353 (cl-defun gptel-make-azure
    354     (name &key curl-args host
    355           (protocol "https")
    356           (header (lambda () `(("api-key" . ,(gptel--get-api-key)))))
    357           (key 'gptel-api-key)
    358           models stream endpoint request-params)
    359   "Register an Azure backend for gptel with NAME.
    360 
    361 Keyword arguments:
    362 
    363 CURL-ARGS (optional) is a list of additional Curl arguments.
    364 
    365 HOST is the API host.
    366 
    367 MODELS is a list of available model names, as symbols.
    368 
    369 STREAM is a boolean to toggle streaming responses, defaults to
    370 false.
    371 
    372 PROTOCOL (optional) specifies the protocol, https by default.
    373 
    374 ENDPOINT is the API endpoint for completions.
    375 
    376 HEADER (optional) is for additional headers to send with each
    377 request.  It should be an alist or a function that retuns an
    378 alist, like:
    379  ((\"Content-Type\" . \"application/json\"))
    380 
    381 KEY (optional) is a variable whose value is the API key, or
    382 function that returns the key.
    383 
    384 REQUEST-PARAMS (optional) is a plist of additional HTTP request
    385 parameters (as plist keys) and values supported by the API.  Use
    386 these to set parameters that gptel does not provide user options
    387 for.
    388 
    389 Example:
    390 -------
    391 
    392  (gptel-make-azure
    393   \"Azure-1\"
    394   :protocol \"https\"
    395   :host \"RESOURCE_NAME.openai.azure.com\"
    396   :endpoint
    397   \"/openai/deployments/DEPLOYMENT_NAME/completions?api-version=2023-05-15\"
    398   :stream t
    399   :models \\='(gpt-3.5-turbo gpt-4))"
    400   (declare (indent 1))
    401   (let ((backend (gptel--make-openai
    402                   :curl-args curl-args
    403                   :name name
    404                   :host host
    405                   :header header
    406                   :key key
    407                   :models (gptel--process-models models)
    408                   :protocol protocol
    409                   :endpoint endpoint
    410                   :stream stream
    411                   :request-params request-params
    412                   :url (if protocol
    413                            (concat protocol "://" host endpoint)
    414                          (concat host endpoint)))))
    415     (prog1 backend
    416       (setf (alist-get name gptel--known-backends
    417                        nil nil #'equal)
    418             backend))))
    419 
    420 ;; GPT4All
    421 ;;;###autoload
    422 (defalias 'gptel-make-gpt4all 'gptel-make-openai
    423   "Register a GPT4All backend for gptel with NAME.
    424 
    425 Keyword arguments:
    426 
    427 CURL-ARGS (optional) is a list of additional Curl arguments.
    428 
    429 HOST is where GPT4All runs (with port), typically localhost:4891
    430 
    431 MODELS is a list of available model names, as symbols.
    432 
    433 STREAM is a boolean to toggle streaming responses, defaults to
    434 false.
    435 
    436 PROTOCOL specifies the protocol, https by default.
    437 
    438 ENDPOINT (optional) is the API endpoint for completions, defaults to
    439 \"/api/v1/completions\"
    440 
    441 HEADER (optional) is for additional headers to send with each
    442 request. It should be an alist or a function that retuns an
    443 alist, like:
    444 ((\"Content-Type\" . \"application/json\"))
    445 
    446 KEY (optional) is a variable whose value is the API key, or
    447 function that returns the key. This is typically not required for
    448 local models like GPT4All.
    449 
    450 REQUEST-PARAMS (optional) is a plist of additional HTTP request
    451 parameters (as plist keys) and values supported by the API.  Use
    452 these to set parameters that gptel does not provide user options
    453 for.
    454 
    455 Example:
    456 -------
    457 
    458 (gptel-make-gpt4all
    459  \"GPT4All\"
    460  :protocol \"http\"
    461  :host \"localhost:4891\"
    462  :models \\='(mistral-7b-openorca.Q4_0.gguf))")
    463 
    464 (provide 'gptel-openai)
    465 ;;; gptel-openai.el ends here