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