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