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