From c8caf17aa138750af16501e6de6d41b7d192d014 Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Sun, 31 Mar 2024 13:07:36 -0500 Subject: [PATCH 01/28] Use robby-api-url when getting models --- robby-models.el | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/robby-models.el b/robby-models.el index 807226d..529857c 100644 --- a/robby-models.el +++ b/robby-models.el @@ -20,7 +20,7 @@ Make request to OpenAI API to get the list of available models." robby-models (let* ((inhibit-message t) (message-log-max nil) - (url "https://api.openai.com/v1/models") + (url robby-api-url) (url-request-method "GET") (url-request-extra-headers `(("Content-Type" . "application/json") From d388e9e3fcdec806a2dfd757abb9147914626ddf Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Sun, 31 Mar 2024 13:08:03 -0500 Subject: [PATCH 02/28] Log raw curl response --- robby-request.el | 1 + 1 file changed, 1 insertion(+) diff --git a/robby-request.el b/robby-request.el index 0f1fd13..184be8c 100644 --- a/robby-request.el +++ b/robby-request.el @@ -111,6 +111,7 @@ STREAMP is non-nil if the response is a stream." (set-process-filter proc (lambda (proc string) + (robby--log (format "# Raw curl response chunk:\n%s\n" string)) (condition-case err (let ((error-msg (robby--request-parse-error-string string))) (if error-msg From 908e27e6313897e3ebeb7b881aa6db8f440899c6 Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Sun, 31 Mar 2024 13:08:25 -0500 Subject: [PATCH 03/28] Fix bug setting robby -openai-api-key --- robby-api-key.el | 12 +++--------- robby-customization.el | 9 +++++++++ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/robby-api-key.el b/robby-api-key.el index fa29c1f..46f7c3f 100644 --- a/robby-api-key.el +++ b/robby-api-key.el @@ -7,6 +7,9 @@ ;;; Code: (require 'auth-source) +;; defined in robby-custom.el: +(defvar robby-openai-api-key) + (defun robby--get-api-key-from-auth-source () "Get api key from auth source." (if-let ((secret (plist-get (car (auth-source-search @@ -19,15 +22,6 @@ secret) (user-error "No `robby-api-key' found in auth source"))) -(defcustom robby-openai-api-key #'robby--get-api-key-from-auth-source - "OpenAI API key. - -A string, or a function that returns the API key." - :group 'robby - :type '(choice - (string :tag "OpenAI API key") - (function :tag "Function that returns the OpenAI API key"))) - (defun robby--get-api-key () "Get api key from `robby-api-key'." (cond diff --git a/robby-customization.el b/robby-customization.el index de2b8d7..c2f9c1e 100644 --- a/robby-customization.el +++ b/robby-customization.el @@ -30,6 +30,15 @@ Return an error message if the value is invalid, or nil if it is valid." :group 'tools :tag "robby") +(defcustom robby-openai-api-key #'robby--get-api-key-from-auth-source + "OpenAI API key. + +A string, or a function that returns the API key." + :group 'robby + :type '(choice + (string :tag "OpenAI API key") + (function :tag "Function that returns the OpenAI API key"))) + (defcustom robby-logging nil "Log to *robby-log* buffer if t." :type 'boolean From 86074a5b20f881077221b6243a8900f6cdc6979d Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Mon, 1 Apr 2024 07:44:35 -0500 Subject: [PATCH 04/28] lazy load robby models --- robby-transients.el | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/robby-transients.el b/robby-transients.el index 5d00394..c541034 100644 --- a/robby-transients.el +++ b/robby-transients.el @@ -197,11 +197,12 @@ Only includes options that cannot be nil.") (oset obj value `(,@(robby--options-transient-value)))) ;;; robby-api-options +;;;###autoload (autoload 'robby-api-options "robby" "Chat API options transient." t) (transient-define-prefix robby-api-options () - "Chat API option transient." + "Chat API options transient." :init-value 'robby--init-api-options ["Chat API Options" - ("m" "model" "model=" :always-read t :choices ,(robby--get-models)) + ("m" "model" "model=" :always-read t :choices (lambda () (robby--get-models))) ("t" "max tokens" "max-tokens=" :reader transient-read-number-N+ :always-read t) ("e" "temperature" "temperature=" :reader robby--read-temperature :always-read t) ("p" "top p" "top-p=" :reader robby--read-top-p :always-read t) From 2e097464cb53edf22fbae99db10df29c346c59be Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Mon, 1 Apr 2024 07:45:01 -0500 Subject: [PATCH 05/28] Refactor API request functions and add chat URL function --- robby-request.el | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/robby-request.el b/robby-request.el index 184be8c..739a92a 100644 --- a/robby-request.el +++ b/robby-request.el @@ -68,8 +68,6 @@ of parsed JSON objects: (setq new-remaining (buffer-substring pos (point-max)))))) `(:remaining ,new-remaining :parsed ,(nreverse parsed))))) -(defconst robby--curl-unknown-error "Unexpected error making OpenAI request via curl" ) - (defun robby--curl-parse-response (string remaining streamp) "Parse JSON curl response from data in STRING and REMAINING unparsed text. @@ -80,6 +78,10 @@ STREAMP is non-nil if the response is a stream." (text (string-join (seq-filter #'stringp (seq-map (lambda (chunk) (robby--chunk-content chunk streamp)) parsed))))) `(:text ,text :remaining ,(plist-get json :remaining)))) +(defun robby--chat-url () + "Get the chat API URL." + (concat robby-api-url "/chat/completions")) + (cl-defun robby--curl (&key payload on-text on-error streamp) "Make a request to the OpenAI API using curl. @@ -100,9 +102,10 @@ STREAMP is non-nil if the response is a stream." "curl" proc-buffer "curl" - robby-api-url + (robby--chat-url) curl-options) (error (funcall on-error err))))) + (robby--log (format "# Curl request command:\ncurl %s %s\n" (robby--chat-url) (string-join curl-options " "))) (let ((remaining "") (text "") (errored nil)) @@ -122,8 +125,8 @@ STREAMP is non-nil if the response is a stream." (setq remaining (plist-get resp :remaining)) (funcall on-text :text (plist-get resp :text) :completep nil)))) (error - (kill-process proc) - (error "Robby: unexpected error processing curl response: %S" err)))))) + (error "Robby: unexpected error processing curl response: %S" err) + (kill-process proc)))))) (set-process-sentinel proc (lambda (_proc _status) @@ -160,9 +163,8 @@ ON-ERROR is the callback for when an error is received." ("Authorization" . ,(concat "Bearer " (robby--get-api-key))))) (inhibit-message t) (message-log-max nil)) - (robby--log (format "#url-retrieve request JSON payload:\n%s\n" url-request-data)) (url-retrieve - robby-api-url + (robby--chat-url) (lambda (_status) (goto-char (point-min)) (re-search-forward "^{") From 097710f14d2aa88987e7a59297084cdaf78f841f Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Mon, 1 Apr 2024 07:45:20 -0500 Subject: [PATCH 06/28] fix models URL --- robby-models.el | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/robby-models.el b/robby-models.el index 529857c..8c11db2 100644 --- a/robby-models.el +++ b/robby-models.el @@ -20,7 +20,7 @@ Make request to OpenAI API to get the list of available models." robby-models (let* ((inhibit-message t) (message-log-max nil) - (url robby-api-url) + (url (concat robby-api-url "/models")) (url-request-method "GET") (url-request-extra-headers `(("Content-Type" . "application/json") From 76545a21f1e1c528bc55e2fdcfa3eadac0b6f046 Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Mon, 1 Apr 2024 07:45:42 -0500 Subject: [PATCH 07/28] Add to robby-api-url docstring --- robby-customization.el | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/robby-customization.el b/robby-customization.el index c2f9c1e..075ca97 100644 --- a/robby-customization.el +++ b/robby-customization.el @@ -97,8 +97,10 @@ It should include a `%s' placeholder for the spinner." :type 'string :group 'robby) -(defcustom robby-api-url "https://api.openai.com/v1/chat/completions" - "URL to use for OpenAI API requests." +(defcustom robby-api-url "https://api.openai.com/v1" + "Base URL to use for OpenAI API requests. + +It should not end with a trailing slash. Robby will append paths to the URL like `/chat/models'" :type 'string :group 'robby) From b17441cd5e1f74e27729a1407b09f4fda77d7bc6 Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Tue, 2 Apr 2024 07:28:41 -0500 Subject: [PATCH 08/28] Make robby log buffer read-only --- robby-logging.el | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/robby-logging.el b/robby-logging.el index ab327ca..9982da2 100644 --- a/robby-logging.el +++ b/robby-logging.el @@ -14,9 +14,9 @@ "Insert MSG in `robby--log' buffer." (if robby-logging (with-current-buffer (get-buffer-create robby--log-buffer) - (insert msg)))) - -(provide 'robby-logging) + (setq buffer-read-only nil) + (insert msg) + (setq buffer-read-only t)))) (provide 'robby-logging) From c0f796286dd907f815f6ed9f5f76558842b987ff Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Tue, 2 Apr 2024 07:30:01 -0500 Subject: [PATCH 09/28] support multiple providers --- robby-api-key.el | 7 +++-- robby-customization.el | 68 +++++++++++++++++++++++++++++++++++------- robby-models.el | 19 ++++++++---- robby-providers.el | 40 +++++++++++++++++++++++++ robby-request.el | 14 +++++---- 5 files changed, 123 insertions(+), 25 deletions(-) create mode 100644 robby-providers.el diff --git a/robby-api-key.el b/robby-api-key.el index 46f7c3f..61340f8 100644 --- a/robby-api-key.el +++ b/robby-api-key.el @@ -7,13 +7,16 @@ ;;; Code: (require 'auth-source) -;; defined in robby-custom.el: +;; declared in robby-customization.el (defvar robby-openai-api-key) +;; declared in robby-providers.el +(declare-function robby--providers-host ()) + (defun robby--get-api-key-from-auth-source () "Get api key from auth source." (if-let ((secret (plist-get (car (auth-source-search - :host "api.openai.com" + :host (robby--providers-host) :user "apikey" :require '(:secret))) :secret))) diff --git a/robby-customization.el b/robby-customization.el index 075ca97..71bc93c 100644 --- a/robby-customization.el +++ b/robby-customization.el @@ -6,6 +6,7 @@ ;;; Code: +(require 'map) (require 'spinner) (require 'robby-api-key) @@ -92,26 +93,71 @@ It should include a `%s' placeholder for the spinner." :type 'string :group 'robby) -(defcustom robby-chat-system-message "You are an AI tool embedded within Emacs. Assist users with their tasks and provide information as needed. Do not engage in harmful or malicious behavior. Please provide helpful information. Answer concisely." +(defcustom robby-chat-system-message + "You are an AI tool embedded within Emacs. Assist users with their tasks and provide information as needed. Do not engage in harmful or malicious behavior. Please provide helpful information. Answer concisely." "System message to use with OpenAI Chat API." :type 'string :group 'robby) -(defcustom robby-api-url "https://api.openai.com/v1" - "Base URL to use for OpenAI API requests. - -It should not end with a trailing slash. Robby will append paths to the URL like `/chat/models'" - :type 'string - :group 'robby) - ;;; chat api options (defgroup robby-chat-api nil "Options to pass to the chat API." :group 'robby) -(defcustom robby-chat-model "gpt-3.5-turbo" - "The model to use with the completions API." - :type 'string +(defcustom robby-providers-settings + '((openai + . (:host "api.openai.com" + :default-model "gpt-3.5-turbo" + :api-base-path "/v1" + :models-filter-re "\\`gpt")) + (mistral + . (:host "api.mistral.ai" + :default-model "mistral-small-latest" + :api-base-path "/v1")) + (togetherai + . (:host "api.together.xyz" + :default-model "mistral-small-latest" + :api-base-path "/v1"))) + "Alist of AI providers and their settings." + :type 'sexp + :group 'robby) + +(defcustom robby-provider 'mistral + "The AI provider to use." + :type '(choice (const :tag "Mistral" mistral) + (const :tag "TogetherAI" togetherai) + (const :tag "OpenAI" openai)) + :group 'robby) + +(defun robby--provider-name (provider) + "Format AI provider name from PROVIDER symbol. + +For example `'openai' becomes \"OpenAI\"." + (string-replace "ai" "AI" (capitalize (symbol-name provider)))) + +(defun robby--providers-type () + "Get the `robby-provider' custom type. + +Get the customizatoin type from `robby-providers-settings', +including a choice for each provider." + `(choice + ,@(seq-map + (lambda (provider) + `(const :tag ,(robby--provider-name provider) ,provider)) + (map-keys robby-providers-settings)))) + +(defcustom robby-provider 'openai + "The AI provider to use." + :type '(choice (const :tag "Mistral" mistral) + (const :tag "TogetherAI" togetherai) + (const :tag "OpenAI" openai)) + :group 'robby) + +(defcustom robby-chat-model nil + "The model to use with the chat completions API. + +Nil means use the default model for the provider." + :type '(choice string (const nil)) :group 'robby-chat-api) (defcustom robby-chat-max-tokens 2000 diff --git a/robby-models.el b/robby-models.el index 8c11db2..81be157 100644 --- a/robby-models.el +++ b/robby-models.el @@ -9,18 +9,23 @@ (require 'robby-api-key) (require 'robby-request) (require 'robby-customization) +(require 'robby-providers) -(defvar robby-models nil) +(defvar robby--models nil) + +(defun robby--models-url () + "Get the URL for the models endpoint." + (concat "https://" (robby--providers-host) (robby--providers-api-base-path) "/models")) (defun robby--get-models () "Get the list of available models from OpenAI. Make request to OpenAI API to get the list of available models." - (if robby-models - robby-models + (if robby--models + robby--models (let* ((inhibit-message t) (message-log-max nil) - (url (concat robby-api-url "/models")) + (url (robby--models-url)) (url-request-method "GET") (url-request-extra-headers `(("Content-Type" . "application/json") @@ -37,8 +42,10 @@ Make request to OpenAI API to get the list of available models." (if err (error "Error fetching models: %S" err) (let* ((all-models (seq-map (lambda (obj) (cdr (assoc 'id obj))) (cdr (assoc 'data resp)))) - (gpt-models (seq-filter (lambda (name) (string-prefix-p "gpt" name)) all-models))) - (setq robby-models gpt-models)))))))) + (filtered-models (if (robby--providers-models-filter-re) + (seq-filter (lambda (name) (string-prefix-p (robby--providers-models-filter-re) name)) all-models) + all-models))) + (setq robby--models filtered-models)))))))) (provide 'robby-models) diff --git a/robby-providers.el b/robby-providers.el new file mode 100644 index 0000000..198d5c5 --- /dev/null +++ b/robby-providers.el @@ -0,0 +1,40 @@ +;;; robby-providers.el --- AI Provider Backends -*- lexical-binding:t -*- + +;;; Commentary: + +;; Defines a generic function and implementions to customize robby for +;; various AI backends. + +;;; Code: + +;; declared in robby-customization.el +(defvar robby-provider) +(defvar robby-providers-settings) + +(defun robby--provider-settings () + "Return the settings of the current provider." + (alist-get robby-provider robby-providers-settings)) + +(defun robby--providers-host () + "Return the host of the current provider." + (plist-get (robby--provider-settings) :host)) + +(defun robby--providers-default-model () + "Return the default model to use with the the current provider." + (plist-get (robby--provider-settings) :default-model)) + +(defun robby--providers-api-base-path () + "Return the host of the current provider." + (plist-get (robby--provider-settings) :api-base-path)) + +(defun robby--providers-models-filter-re () + "Return regexp to filter models by for the current provider. + +Nil means do not filter the models list. OpenAI returns models +that are not usable with the chat API, so we have to filter to +gpt*." + (plist-get (robby--provider-settings) :models-filter-re)) + +(provide 'robby-providers) + +;;; robby-providers.el ends here diff --git a/robby-request.el b/robby-request.el index 739a92a..f2c8636 100644 --- a/robby-request.el +++ b/robby-request.el @@ -15,9 +15,10 @@ (require 'robby-api-key) (require 'robby-customization) (require 'robby-logging) +(require 'robby-providers) (require 'robby-utils) -;;; util functions +;;; request util functions (defun robby--request-parse-error-data (data) "Get error from response DATA." (cdr (assoc 'message (assoc 'error data)))) @@ -28,6 +29,10 @@ (robby--request-parse-error-data (json-read-from-string err)) (error nil))) +(defun robby--chat-url () + "Get the chat API URL." + (concat "https://" (robby--providers-host) (robby--providers-api-base-path) "/chat/completions")) + ;;; curl (defvar robby--curl-options '("--compressed" @@ -78,10 +83,6 @@ STREAMP is non-nil if the response is a stream." (text (string-join (seq-filter #'stringp (seq-map (lambda (chunk) (robby--chunk-content chunk streamp)) parsed))))) `(:text ,text :remaining ,(plist-get json :remaining)))) -(defun robby--chat-url () - "Get the chat API URL." - (concat robby-api-url "/chat/completions")) - (cl-defun robby--curl (&key payload on-text on-error streamp) "Make a request to the OpenAI API using curl. @@ -160,12 +161,13 @@ ON-ERROR is the callback for when an error is received." (encode-coding-string (json-encode payload) 'utf-8)) (url-request-extra-headers `(("Content-Type" . "application/json") - ("Authorization" . ,(concat "Bearer " (robby--get-api-key))))) + ("Authorization:" . ,(concat "Bearer " (robby--get-api-key))))) (inhibit-message t) (message-log-max nil)) (url-retrieve (robby--chat-url) (lambda (_status) + (robby--log (format "# URL retrieve response buffer contents: %s" (buffer-substring-no-properties (point-min) (point-max)))) (goto-char (point-min)) (re-search-forward "^{") (backward-char 1) From 3e07599334ca75157d3a28f44c1fb1fb80d6559a Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Tue, 2 Apr 2024 07:30:21 -0500 Subject: [PATCH 10/28] Make sure to stop the spinner when killing robby process --- robby-process.el | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/robby-process.el b/robby-process.el index 1304ec3..d4c708b 100644 --- a/robby-process.el +++ b/robby-process.el @@ -26,9 +26,9 @@ Emacs Lisp, do not print messages if SILENTP is t. Note that you cannot currently kill the last robby process if you are using `url-retreive'; you must be using `curl'" (interactive) + (robby--spinner-stop) (if (robby--process-running-p) (progn - (robby--spinner-stop) (kill-process robby--last-process) (when (not silentp) (message "robby process killed"))) From a1eaa260946919f7fe896ee623ce5d8012af5f93 Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Tue, 2 Apr 2024 07:31:33 -0500 Subject: [PATCH 11/28] Fix bug losing history with streaming responses --- robby-run-command.el | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/robby-run-command.el b/robby-run-command.el index 529a74e..29930ae 100644 --- a/robby-run-command.el +++ b/robby-run-command.el @@ -116,6 +116,7 @@ of the entire buffer." no-op-pattern no-op-message text + text-processed response-buffer response-region) "Process a cunk of text received from OpenAI. @@ -142,6 +143,8 @@ matches. TEXT is the response from OpenAI. It may be one chunk of the response if streaming is on. +TEXT-PROCESSED is the text processed so far, not including the new TEXT. + RESPONSE-BUFFER is the buffer where the response is written to. RESPONSE-REGION is the region to prepend, append, or replace in @@ -153,7 +156,7 @@ RESPONSE-BUFFER." (end (cdr response-region)) (grounded-text (robby--ground-response text grounding-fns))) (when completep - (robby--history-push basic-prompt text)) + (robby--history-push basic-prompt (concat text text-processed))) (if (and no-op-pattern (string-match-p no-op-pattern text)) (message (or no-op-message) "no action to perform") (when (or completep (> (length grounded-text) 0)) @@ -274,7 +277,7 @@ value overrides the `robby-stream' customization variable." (response-buffer (get-buffer-create (robby--get-response-buffer action action-args))) (response-region (robby--get-response-region response-buffer)) (streamp (robby--get-stream-p :never-stream-p never-stream-p :no-op-pattern no-op-pattern :grounding-fns grounding-fns)) - (chars-processed 0)) + (text-processed "")) (robby--log (format "# Request body alist:\n%s\n" payload)) @@ -298,16 +301,17 @@ value overrides the `robby-stream' customization variable." :action-args action-args :arg arg :basic-prompt basic-prompt - :chars-processed chars-processed + :chars-processed (length text-processed) :completep completep :grounding-fns grounding-fns :no-op-pattern no-op-pattern :no-op-message no-op-message :response-buffer response-buffer :response-region response-region - :text text)) + :text text + :text-processed text-processed)) (error (robby--handle-error err)))) - (setq chars-processed (+ chars-processed (length text))))) + (setq text-processed (concat text text-processed)))) :on-error (lambda (err) (with-current-buffer response-buffer From 18f49b1055c05f50de3c1ebb493d6b0676e6824e Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Tue, 2 Apr 2024 07:32:11 -0500 Subject: [PATCH 12/28] Added new test for robby--request-input --- test/robby-utils-test.el | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/robby-utils-test.el b/test/robby-utils-test.el index 165f5c5..7f8aee6 100644 --- a/test/robby-utils-test.el +++ b/test/robby-utils-test.el @@ -86,6 +86,23 @@ ((role . "user") (content . "Where was it played?")) ]))))) +(ert-deftest robby--request-input--with-two-itemhistory () + (should (equal + (robby--request-input + "Bonjour!" + t + '(("hi" . "Hello!") ("answer in french" . "Bien sûr!")) + "I am a helpful assistant.") + + `((messages . + [((role . "system") (content . "I am a helpful assistant.")) + ((role . "user") (content . "hi")) + ((role . "assistant") (content . "Hello!")) + ((role . "user") (content . "answer in french")) + ((role . "assistant") (content . "Bien sûr!")) + ((role . "user") (content . "Bonjour!")) + ]))))) + ;;; chunk content tests (ert-deftest robby--chunk-content--no-streaming () (let ((resp '((choices . [((index . 0) From feb7f00fc088ddd89b2eeaaea72dd792f2d77024 Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Wed, 3 Apr 2024 07:41:23 -0500 Subject: [PATCH 13/28] Fix togetherai default model --- robby-customization.el | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/robby-customization.el b/robby-customization.el index 71bc93c..7812f98 100644 --- a/robby-customization.el +++ b/robby-customization.el @@ -116,7 +116,7 @@ It should include a `%s' placeholder for the spinner." :api-base-path "/v1")) (togetherai . (:host "api.together.xyz" - :default-model "mistral-small-latest" + :default-model "togethercomputer/StripedHyena-Nous-7B" :api-base-path "/v1"))) "Alist of AI providers and their settings." :type 'sexp @@ -138,7 +138,7 @@ For example `'openai' becomes \"OpenAI\"." (defun robby--providers-type () "Get the `robby-provider' custom type. -Get the customizatoin type from `robby-providers-settings', +Get the customization type from `robby-providers-settings', including a choice for each provider." `(choice ,@(seq-map From 8aa86129a34005c139b2f9559b50ad7157bee1da Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Wed, 3 Apr 2024 07:41:49 -0500 Subject: [PATCH 14/28] Pass default model for provider if no model specified in API options Mistral will return an error if no model is provided in the request. --- robby-request.el | 10 +++++++++- robby-utils.el | 4 ++++ test/robby-utils-test.el | 12 ++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/robby-request.el b/robby-request.el index f2c8636..1fe96fc 100644 --- a/robby-request.el +++ b/robby-request.el @@ -21,7 +21,14 @@ ;;; request util functions (defun robby--request-parse-error-data (data) "Get error from response DATA." - (cdr (assoc 'message (assoc 'error data)))) + (or + ;; openai, together: + (cdr (assoc 'message (assoc 'error data))) + ;; mistral: + (let ((object (cdr (assoc 'object data)))) + (if (string= object "error") + (cdr (assoc 'message data)) + nil)))) (defun robby--request-parse-error-string (err) "Get error from JSON string ERR." @@ -116,6 +123,7 @@ STREAMP is non-nil if the response is a stream." proc (lambda (proc string) (robby--log (format "# Raw curl response chunk:\n%s\n" string)) + (robby--log (format "# proc %S" proc)) (condition-case err (let ((error-msg (robby--request-parse-error-string string))) (if error-msg diff --git a/robby-utils.el b/robby-utils.el index 93b2405..62df415 100644 --- a/robby-utils.el +++ b/robby-utils.el @@ -11,6 +11,8 @@ (require 'map) (require 'seq) +(require 'robby-providers) + ;;; string utils (defun robby--format-message-text (text) "Replace % with %% in TEXT to avoid format string errors calling `message." @@ -100,7 +102,9 @@ pass, where the keys are strings." #'car #'string< (robby--to-stop-array-vals (map-merge + ;; add default model if no model specified, since mistral will return an error if no model is specified 'alist + `(("model" . ,(robby--providers-default-model))) (seq-filter (lambda (elem) (not (null (cdr elem)))) (robby--options-from-group 'robby-chat-api)) diff --git a/test/robby-utils-test.el b/test/robby-utils-test.el index 7f8aee6..333d0df 100644 --- a/test/robby-utils-test.el +++ b/test/robby-utils-test.el @@ -2,6 +2,7 @@ (require 'ert) +(require 'robby-customization) (require 'robby-utils) ;;; Code: @@ -54,6 +55,17 @@ ("model" . "gpt-4") ("temperature" . 1.0)))))) +(ert-deftest robby--options-alist-for-api-request-default-model-for-provider () + (let ((robby-provider 'openai) + (robby-chat-model nil) + (robby-chat-max-tokens 100) + (robby-chat-temperature 1.0)) + (message "provider: %S, default model %S" robby-provider (robby--providers-default-model)) + (should (equal (robby--options-alist-for-api-request '(:max-tokens 2)) + '(("max_tokens" . 2) + ("model" . "gpt-3.5-turbo") + ("temperature" . 1.0)))))) + (ert-deftest robby--options-alist-for-api-request-returns-stop-array () ;; we only support a single "stop" string, but the API expects an array (let ((robby-chat-model "gpt-4") From 49be61211a4870468d65359c01659894ec355e83 Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Wed, 3 Apr 2024 18:29:40 -0500 Subject: [PATCH 15/28] Add clean-compiled as dependency to compile in Makefile --- Makefile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 37993a3..3b8e5a3 100644 --- a/Makefile +++ b/Makefile @@ -30,9 +30,11 @@ test: install -l ./test/robby-grounding-fns-test.el \ -l ./test/robby-history-test.el \ -l ./test/robby-logging-test.el \ + -l ./test/robby-providers-test.el \ -l ./test/robby-request-test.el \ -l ./test/robby-test-env.el \ -l ./test/robby-utils-test.el \ + -l ./test/robby-validation-test.el \ -eval '(ert-run-tests-batch-and-exit "$(MATCH)")' EL_FILES := $(wildcard *.el) @@ -41,7 +43,7 @@ EL_FILES := $(wildcard *.el) checkdoc: for FILE in ${EL_FILES}; do $(EMACS) --batch -L . -l ./test/robby-test-env.el -eval "(checkdoc-file \"$$FILE\")" ; done -compile: install +compile: install clean-compiled $(EMACS) --batch -L . -l ./test/robby-test-env.el -f batch-byte-compile robby-*.el lint: install From ce5427059edfff89603ffadb25f23c72f5e28740 Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Wed, 3 Apr 2024 18:30:32 -0500 Subject: [PATCH 16/28] Refactor providers logic to remove circular deps and simplify --- robby-customization.el | 33 +++--------------------- robby-models.el | 9 ++++--- robby-providers.el | 49 +++++++++++++++++++++++++++++++----- robby-request.el | 15 ++--------- robby-run-command.el | 15 +++++++++-- robby-utils.el | 4 --- test/robby-providers-test.el | 26 +++++++++++++++++++ test/robby-utils-test.el | 12 --------- 8 files changed, 94 insertions(+), 69 deletions(-) create mode 100644 test/robby-providers-test.el diff --git a/robby-customization.el b/robby-customization.el index 7812f98..9e817e8 100644 --- a/robby-customization.el +++ b/robby-customization.el @@ -104,29 +104,11 @@ It should include a `%s' placeholder for the spinner." "Options to pass to the chat API." :group 'robby) -(defcustom robby-providers-settings - '((openai - . (:host "api.openai.com" - :default-model "gpt-3.5-turbo" - :api-base-path "/v1" - :models-filter-re "\\`gpt")) - (mistral - . (:host "api.mistral.ai" - :default-model "mistral-small-latest" - :api-base-path "/v1")) - (togetherai - . (:host "api.together.xyz" - :default-model "togethercomputer/StripedHyena-Nous-7B" - :api-base-path "/v1"))) - "Alist of AI providers and their settings." - :type 'sexp - :group 'robby) - -(defcustom robby-provider 'mistral +(defcustom robby-provider 'openai "The AI provider to use." :type '(choice (const :tag "Mistral" mistral) - (const :tag "TogetherAI" togetherai) - (const :tag "OpenAI" openai)) + (const :tag "OpenAI" openai) + (const :tag "TogetherAI" togetherai)) :group 'robby) (defun robby--provider-name (provider) @@ -144,14 +126,7 @@ including a choice for each provider." ,@(seq-map (lambda (provider) `(const :tag ,(robby--provider-name provider) ,provider)) - (map-keys robby-providers-settings)))) - -(defcustom robby-provider 'openai - "The AI provider to use." - :type '(choice (const :tag "Mistral" mistral) - (const :tag "TogetherAI" togetherai) - (const :tag "OpenAI" openai)) - :group 'robby) + (map-keys '(mistral openai togetherai))))) (defcustom robby-chat-model nil "The model to use with the chat completions API. diff --git a/robby-models.el b/robby-models.el index 81be157..8aa5556 100644 --- a/robby-models.el +++ b/robby-models.el @@ -15,7 +15,8 @@ (defun robby--models-url () "Get the URL for the models endpoint." - (concat "https://" (robby--providers-host) (robby--providers-api-base-path) "/models")) + ;; (concat "https://" (robby--providers-host) (robby--providers-api-base-path) "/models") + (concat "https://" (robby--providers-host) "/models/info?=")) (defun robby--get-models () "Get the list of available models from OpenAI. @@ -33,12 +34,14 @@ Make request to OpenAI API to get the list of available models." (inhibit-message t) (message-log-max nil)) (with-current-buffer (url-retrieve-synchronously url) + (robby--log (format "Models response: %s\n" (buffer-string))) (goto-char (point-min)) - (re-search-forward "^{") + ;; (re-search-forward "^{") + (re-search-forward "^[[{]") (backward-char 1) (let* ((json-object-type 'alist) (resp (json-read)) - (err (robby--request-parse-error-data resp))) + (err (robby--providers-parse-error resp))) (if err (error "Error fetching models: %S" err) (let* ((all-models (seq-map (lambda (obj) (cdr (assoc 'id obj))) (cdr (assoc 'data resp)))) diff --git a/robby-providers.el b/robby-providers.el index 198d5c5..e528640 100644 --- a/robby-providers.el +++ b/robby-providers.el @@ -11,21 +11,38 @@ (defvar robby-provider) (defvar robby-providers-settings) -(defun robby--provider-settings () +(defvar robby--providers-settings + '((openai + . (:host "api.openai.com" + :default-model "gpt-3.5-turbo" + :api-base-path "/v1" + :models-filter-re "\\`gpt")) + (mistral + . (:host "api.mistral.ai" + :default-model "mistral-small-latest" + :api-base-path "/v1")) + (togetherai + . (:host "api.together.xyz" + :default-model "togethercomputer/StripedHyena-Nous-7B" + :api-base-path "/v1"))) + "Association of AI providers and their settings.") + +;;; Functions that can retreive settings from the provider settings +(defun robby--get-provider-settings () "Return the settings of the current provider." - (alist-get robby-provider robby-providers-settings)) + (alist-get robby-provider robby--providers-settings)) (defun robby--providers-host () "Return the host of the current provider." - (plist-get (robby--provider-settings) :host)) + (plist-get (robby--get-provider-settings) :host)) (defun robby--providers-default-model () "Return the default model to use with the the current provider." - (plist-get (robby--provider-settings) :default-model)) + (plist-get (robby--get-provider-settings) :default-model)) (defun robby--providers-api-base-path () "Return the host of the current provider." - (plist-get (robby--provider-settings) :api-base-path)) + (plist-get (robby--get-provider-settings) :api-base-path)) (defun robby--providers-models-filter-re () "Return regexp to filter models by for the current provider. @@ -33,7 +50,27 @@ Nil means do not filter the models list. OpenAI returns models that are not usable with the chat API, so we have to filter to gpt*." - (plist-get (robby--provider-settings) :models-filter-re)) + (plist-get (robby--get-provider-settings) :models-filter-re)) + +;;; parse-error +;; a generic method to parse the error from the response when the curent value of robby-provider is 'mistral +(cl-defgeneric robby--providers-parse-error (data) + "Get error from response DATA. + +Different providers have different response formats for errors.") + +(cl-defmethod robby--providers-parse-error (data) + "Get error from response DATA." + (cdr (assoc 'message (assoc 'error data)))) + +(cl-defmethod robby--providers-parse-error (data &context (robby-provider (eql 'mistral))) + "Get error from response DATA. + +This is a specific implementation when ROBBY-PROVIDER is the `mistral' provider." + (let ((object (cdr (assoc 'object data)))) + (if (string= object "error") + (cdr (assoc 'message data)) + nil))) (provide 'robby-providers) diff --git a/robby-request.el b/robby-request.el index 1fe96fc..ac31a56 100644 --- a/robby-request.el +++ b/robby-request.el @@ -19,21 +19,10 @@ (require 'robby-utils) ;;; request util functions -(defun robby--request-parse-error-data (data) - "Get error from response DATA." - (or - ;; openai, together: - (cdr (assoc 'message (assoc 'error data))) - ;; mistral: - (let ((object (cdr (assoc 'object data)))) - (if (string= object "error") - (cdr (assoc 'message data)) - nil)))) - (defun robby--request-parse-error-string (err) "Get error from JSON string ERR." (condition-case _err - (robby--request-parse-error-data (json-read-from-string err)) + (robby--providers-parse-error (json-read-from-string err)) (error nil))) (defun robby--chat-url () @@ -181,7 +170,7 @@ ON-ERROR is the callback for when an error is received." (backward-char 1) (let* ((json-object-type 'alist) (resp (json-read)) - (err (robby--request-parse-error-data resp))) + (err (robby--providers-parse-error resp))) (if err (funcall on-error err) (let ((text (robby--chunk-content resp nil))) diff --git a/robby-run-command.el b/robby-run-command.el index 29930ae..2f4faa7 100644 --- a/robby-run-command.el +++ b/robby-run-command.el @@ -13,6 +13,7 @@ (require 'robby-customization) (require 'robby-history) (require 'robby-logging) +(require 'robby-providers) (require 'robby-process) (require 'robby-spinner) @@ -94,7 +95,17 @@ DOCSTRING is the command's docstring." (setq quoted-options (plist-put quoted-options :historyp t))) (robby--pp-cmd `(robby-define-command ,name ,docstring ,@quoted-options)))) -;;; run command +;;; robby-run-command and helper functions +(defun robby--get-request-input (api-options) + "Get the request input from API-OPTIONS. + +If no model is specified, use the default model for the provider" + (map-merge + ;; add default model if no model specified, since mistral will return an error if no model is specified + 'alist + `(("model" . ,(robby--providers-default-model))) + (robby--options-alist-for-api-request api-options))) + (defun robby--get-response-region (response-buffer) "Return the region to replace in RESPONSE-BUFFER. @@ -273,7 +284,7 @@ value overrides the `robby-stream' customization variable." (prompt-result (if (functionp prompt) (apply prompt prompt-args-with-arg) (format "%s" prompt))) (basic-prompt (robby--format-prompt prompt-result robby-prompt-spec-fn)) (request-input (robby--request-input basic-prompt historyp robby--history robby-chat-system-message)) - (payload (append request-input (robby--options-alist-for-api-request api-options))) + (payload (append request-input (robby--get-request-input api-options))) (response-buffer (get-buffer-create (robby--get-response-buffer action action-args))) (response-region (robby--get-response-region response-buffer)) (streamp (robby--get-stream-p :never-stream-p never-stream-p :no-op-pattern no-op-pattern :grounding-fns grounding-fns)) diff --git a/robby-utils.el b/robby-utils.el index 62df415..93b2405 100644 --- a/robby-utils.el +++ b/robby-utils.el @@ -11,8 +11,6 @@ (require 'map) (require 'seq) -(require 'robby-providers) - ;;; string utils (defun robby--format-message-text (text) "Replace % with %% in TEXT to avoid format string errors calling `message." @@ -102,9 +100,7 @@ pass, where the keys are strings." #'car #'string< (robby--to-stop-array-vals (map-merge - ;; add default model if no model specified, since mistral will return an error if no model is specified 'alist - `(("model" . ,(robby--providers-default-model))) (seq-filter (lambda (elem) (not (null (cdr elem)))) (robby--options-from-group 'robby-chat-api)) diff --git a/test/robby-providers-test.el b/test/robby-providers-test.el new file mode 100644 index 0000000..abdd031 --- /dev/null +++ b/test/robby-providers-test.el @@ -0,0 +1,26 @@ +;;; robby--providers-test.el --- tests for robby provider functions and methods -*- lexical-binding:t -*- + +(require 'ert) + +(require 'robby-providers) + +;;; robby--providers-parse-error tests +(ert-deftest robby--providers-parse-error-standard () + (let ((robby-provider 'openai)) + (should (equal (robby--providers-parse-error '((error . ((message . "whoops"))))) + "whoops")))) + +(ert-deftest robby--providers-parse-error-standard-no-error () + (let ((robby-provider 'openai)) + (should (eql (robby--providers-parse-error '((not-error . ((message . "whoops"))))) + nil)))) + +(ert-deftest robby--providers-parse-error-mistral () + (let ((robby-provider 'mistral)) + (should (equal (robby--providers-parse-error '((object . "error") (message . "whoops"))) + "whoops")))) + +(ert-deftest robby--providers-parse-error-mistral-no-error () + (let ((robby-provider 'mistral)) + (should (eql (robby--providers-parse-error '((object . "not-error") (message . "whoops"))) + nil)))) diff --git a/test/robby-utils-test.el b/test/robby-utils-test.el index 333d0df..4f8b0cc 100644 --- a/test/robby-utils-test.el +++ b/test/robby-utils-test.el @@ -55,17 +55,6 @@ ("model" . "gpt-4") ("temperature" . 1.0)))))) -(ert-deftest robby--options-alist-for-api-request-default-model-for-provider () - (let ((robby-provider 'openai) - (robby-chat-model nil) - (robby-chat-max-tokens 100) - (robby-chat-temperature 1.0)) - (message "provider: %S, default model %S" robby-provider (robby--providers-default-model)) - (should (equal (robby--options-alist-for-api-request '(:max-tokens 2)) - '(("max_tokens" . 2) - ("model" . "gpt-3.5-turbo") - ("temperature" . 1.0)))))) - (ert-deftest robby--options-alist-for-api-request-returns-stop-array () ;; we only support a single "stop" string, but the API expects an array (let ((robby-chat-model "gpt-4") @@ -167,4 +156,3 @@ (provide 'robby-utils-test) ;;; robby-utils-test.el ends here - From 0d89652f26e623ee27ba3eaf4b1dc0e0c4fd4935 Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Fri, 19 Apr 2024 08:43:28 -0500 Subject: [PATCH 17/28] Add OpenAI and TogetherAI providers, dynamically load custom var --- robby-api-key.el | 4 +- robby-customization.el | 24 +++++---- robby-models.el | 14 ++---- robby-openai-provider.el | 25 ++++++++++ robby-provider.el | 69 ++++++++++++++++++++++++++ robby-providers.el | 77 ----------------------------- robby-request.el | 9 ++-- robby-run-command.el | 96 +++++++++++++++++++++++++++++++++++- robby-togetherai-provider.el | 22 +++++++++ robby.el | 4 ++ test/robby-providers-test.el | 26 ---------- 11 files changed, 236 insertions(+), 134 deletions(-) create mode 100644 robby-openai-provider.el create mode 100644 robby-provider.el delete mode 100644 robby-providers.el create mode 100644 robby-togetherai-provider.el delete mode 100644 test/robby-providers-test.el diff --git a/robby-api-key.el b/robby-api-key.el index 61340f8..4a5ca50 100644 --- a/robby-api-key.el +++ b/robby-api-key.el @@ -11,12 +11,12 @@ (defvar robby-openai-api-key) ;; declared in robby-providers.el -(declare-function robby--providers-host ()) +(declare-function robby--provider-host ()) (defun robby--get-api-key-from-auth-source () "Get api key from auth source." (if-let ((secret (plist-get (car (auth-source-search - :host (robby--providers-host) + :host (robby--provider-host) :user "apikey" :require '(:secret))) :secret))) diff --git a/robby-customization.el b/robby-customization.el index 9e817e8..19a3dd0 100644 --- a/robby-customization.el +++ b/robby-customization.el @@ -104,29 +104,27 @@ It should include a `%s' placeholder for the spinner." "Options to pass to the chat API." :group 'robby) -(defcustom robby-provider 'openai - "The AI provider to use." - :type '(choice (const :tag "Mistral" mistral) - (const :tag "OpenAI" openai) - (const :tag "TogetherAI" togetherai)) - :group 'robby) - (defun robby--provider-name (provider) "Format AI provider name from PROVIDER symbol. For example `'openai' becomes \"OpenAI\"." (string-replace "ai" "AI" (capitalize (symbol-name provider)))) -(defun robby--providers-type () +(defun robby--provider-type () "Get the `robby-provider' custom type. -Get the customization type from `robby-providers-settings', -including a choice for each provider." +Includes a choice for each provider added via +`robby-add-provider'." `(choice ,@(seq-map - (lambda (provider) - `(const :tag ,(robby--provider-name provider) ,provider)) - (map-keys '(mistral openai togetherai))))) + (lambda (provider-settings) + `(const :tag ,(plist-get (cdr robby--provider-settings) :name) ,(car provider-settings))) + robby--provider-settings))) + +(defcustom robby-provider 'openai + "The AI provider to use." + :type (robby--provider-type) + :group 'robby) (defcustom robby-chat-model nil "The model to use with the chat completions API. diff --git a/robby-models.el b/robby-models.el index 8aa5556..1b123ba 100644 --- a/robby-models.el +++ b/robby-models.el @@ -9,14 +9,13 @@ (require 'robby-api-key) (require 'robby-request) (require 'robby-customization) -(require 'robby-providers) +(require 'robby-provider) (defvar robby--models nil) (defun robby--models-url () "Get the URL for the models endpoint." - ;; (concat "https://" (robby--providers-host) (robby--providers-api-base-path) "/models") - (concat "https://" (robby--providers-host) "/models/info?=")) + (concat "https://" (robby--provider-host) (robby--provider-models-path))) (defun robby--get-models () "Get the list of available models from OpenAI. @@ -36,19 +35,14 @@ Make request to OpenAI API to get the list of available models." (with-current-buffer (url-retrieve-synchronously url) (robby--log (format "Models response: %s\n" (buffer-string))) (goto-char (point-min)) - ;; (re-search-forward "^{") (re-search-forward "^[[{]") (backward-char 1) (let* ((json-object-type 'alist) (resp (json-read)) - (err (robby--providers-parse-error resp))) + (err (robby-provider-parse-error resp))) (if err (error "Error fetching models: %S" err) - (let* ((all-models (seq-map (lambda (obj) (cdr (assoc 'id obj))) (cdr (assoc 'data resp)))) - (filtered-models (if (robby--providers-models-filter-re) - (seq-filter (lambda (name) (string-prefix-p (robby--providers-models-filter-re) name)) all-models) - all-models))) - (setq robby--models filtered-models)))))))) + (setq robby--models (robby-provider-parse-models resp)))))))) (provide 'robby-models) diff --git a/robby-openai-provider.el b/robby-openai-provider.el new file mode 100644 index 0000000..fa9f935 --- /dev/null +++ b/robby-openai-provider.el @@ -0,0 +1,25 @@ +;;; robby-openai-provider.el --- robby openai provider -*- lexical-binding:t -*- + +;;; Code: + +(require 'robby-provider) + +(require 'cl-generic) + +(robby-add-provider + :symbol 'openai + :name "OpenAI" + :host "api.openai.com" + :default-model "gpt-3.5-turbo" + :models-path "/v1/models") + +(cl-defmethod robby-provider-parse-models :around (data &context (robby-provider (eql 'openai))) + (let ((models (cl-call-next-method data))) + (seq-filter + (lambda (name) + (string-prefix-p "gpt" name)) + models))) + +(provide 'robby-openai-provider) + +;;; robby-openai-provider.el ends here diff --git a/robby-provider.el b/robby-provider.el new file mode 100644 index 0000000..576afa8 --- /dev/null +++ b/robby-provider.el @@ -0,0 +1,69 @@ +;;; robby-provider.el --- Support Multiple AI Services -*- lexical-binding:t -*- + +;;; Commentary: + +;;; Code: +(require 'cl-generic) + +;; declared in robby-custom.el +(defvar robby-provider) + +(defvar robby--provider-settings nil + "alist of provider settings.") + +;; TODO use this function for each provider upon initialization +(defun robby--add-provider-choice (symbol name) + "Add a provider SYMBOL and NAME to the choices for the +`robby-provider' custom type." + (let* ((new-choice `(const :tag ,name ,symbol)) + (old-choices (cdr (get 'provider 'custom-type))) + (new-choices (cons new-choice old-choices)) + (new-type (cons 'choice new-choices))) + (put 'provider 'custom-type new-type))) + +(defun robby--get-provider-settings () + "Return the settings of the current provider." + (alist-get robby-provider robby--provider-settings)) + +(defun robby--provider-host () + "Return the host of the current provider." + (plist-get (robby--get-provider-settings) :host)) + +(defun robby--provider-default-model () + "Return the default model to use with the the current provider." + (plist-get (robby--get-provider-settings) :default-model)) + +(defun robby--provider-api-base-path () + "Return the API base path of the current provider." + (plist-get (robby--get-provider-settings) :api-base-path)) + +(defun robby--provider-models-path () + "Return the models path of the current provider." + (plist-get (robby--get-provider-settings) :models-path)) + +(cl-defun robby-add-provider (&key symbol name host default-model api-base-path models-path) + "Register a new robby provider." + (let* ((settings `(:name ,name + :host ,host + :default-model ,default-model + :api-base-path ,(or api-base-path "/v1/chat/completions") + :models-path ,(or models-path "/v1/models")))) + (push (cons symbol settings) robby--provider-settings))) + +(cl-defmethod robby-provider-parse-error (data) + "Get error from response DATA. + +DATA is an alist of the JSON parsed response from the provider." + ;; TODO + nil + ;; (cdr (assoc 'message (assoc 'error data))) + ) + +(cl-defmethod robby-provider-parse-models (data) + "Get models from response DATA." + (message "base method data: %S" data) + (seq-map (lambda (obj) (cdr (assoc 'id obj))) (cdr (assoc 'data data)))) + +(provide 'robby-provider) + +;;; robby-provider.el ends here diff --git a/robby-providers.el b/robby-providers.el deleted file mode 100644 index e528640..0000000 --- a/robby-providers.el +++ /dev/null @@ -1,77 +0,0 @@ -;;; robby-providers.el --- AI Provider Backends -*- lexical-binding:t -*- - -;;; Commentary: - -;; Defines a generic function and implementions to customize robby for -;; various AI backends. - -;;; Code: - -;; declared in robby-customization.el -(defvar robby-provider) -(defvar robby-providers-settings) - -(defvar robby--providers-settings - '((openai - . (:host "api.openai.com" - :default-model "gpt-3.5-turbo" - :api-base-path "/v1" - :models-filter-re "\\`gpt")) - (mistral - . (:host "api.mistral.ai" - :default-model "mistral-small-latest" - :api-base-path "/v1")) - (togetherai - . (:host "api.together.xyz" - :default-model "togethercomputer/StripedHyena-Nous-7B" - :api-base-path "/v1"))) - "Association of AI providers and their settings.") - -;;; Functions that can retreive settings from the provider settings -(defun robby--get-provider-settings () - "Return the settings of the current provider." - (alist-get robby-provider robby--providers-settings)) - -(defun robby--providers-host () - "Return the host of the current provider." - (plist-get (robby--get-provider-settings) :host)) - -(defun robby--providers-default-model () - "Return the default model to use with the the current provider." - (plist-get (robby--get-provider-settings) :default-model)) - -(defun robby--providers-api-base-path () - "Return the host of the current provider." - (plist-get (robby--get-provider-settings) :api-base-path)) - -(defun robby--providers-models-filter-re () - "Return regexp to filter models by for the current provider. - -Nil means do not filter the models list. OpenAI returns models -that are not usable with the chat API, so we have to filter to -gpt*." - (plist-get (robby--get-provider-settings) :models-filter-re)) - -;;; parse-error -;; a generic method to parse the error from the response when the curent value of robby-provider is 'mistral -(cl-defgeneric robby--providers-parse-error (data) - "Get error from response DATA. - -Different providers have different response formats for errors.") - -(cl-defmethod robby--providers-parse-error (data) - "Get error from response DATA." - (cdr (assoc 'message (assoc 'error data)))) - -(cl-defmethod robby--providers-parse-error (data &context (robby-provider (eql 'mistral))) - "Get error from response DATA. - -This is a specific implementation when ROBBY-PROVIDER is the `mistral' provider." - (let ((object (cdr (assoc 'object data)))) - (if (string= object "error") - (cdr (assoc 'message data)) - nil))) - -(provide 'robby-providers) - -;;; robby-providers.el ends here diff --git a/robby-request.el b/robby-request.el index ac31a56..7b1ba8e 100644 --- a/robby-request.el +++ b/robby-request.el @@ -15,19 +15,20 @@ (require 'robby-api-key) (require 'robby-customization) (require 'robby-logging) -(require 'robby-providers) +(require 'robby-provider) (require 'robby-utils) ;;; request util functions (defun robby--request-parse-error-string (err) "Get error from JSON string ERR." (condition-case _err - (robby--providers-parse-error (json-read-from-string err)) + (robby-provider-parse-error (json-read-from-string err)) (error nil))) +;; TODO consider passing url to robby--request (defun robby--chat-url () "Get the chat API URL." - (concat "https://" (robby--providers-host) (robby--providers-api-base-path) "/chat/completions")) + (concat "https://" (robby--provider-host) (robby--provider-api-base-path))) ;;; curl (defvar robby--curl-options @@ -170,7 +171,7 @@ ON-ERROR is the callback for when an error is received." (backward-char 1) (let* ((json-object-type 'alist) (resp (json-read)) - (err (robby--providers-parse-error resp))) + (err (robby-provider-parse-error resp))) (if err (funcall on-error err) (let ((text (robby--chunk-content resp nil))) diff --git a/robby-run-command.el b/robby-run-command.el index 2f4faa7..7a0a3c2 100644 --- a/robby-run-command.el +++ b/robby-run-command.el @@ -13,7 +13,7 @@ (require 'robby-customization) (require 'robby-history) (require 'robby-logging) -(require 'robby-providers) +(require 'robby-provider) (require 'robby-process) (require 'robby-spinner) @@ -103,7 +103,7 @@ If no model is specified, use the default model for the provider" (map-merge ;; add default model if no model specified, since mistral will return an error if no model is specified 'alist - `(("model" . ,(robby--providers-default-model))) + `(("model" . ,(robby--provider-default-model))) (robby--options-alist-for-api-request api-options))) (defun robby--get-response-region (response-buffer) @@ -274,6 +274,98 @@ NO-OP-MESSAGE - Message to display when NO-OP-PATTERN matches. Optional. HISTORYP indicates whether or not to use conversation history. +NEVER-STREAM-P - Never stream response if t. If present this +value overrides the `robby-stream' customization variable." + ;; save command history + (robby--save-last-command-options + :prompt prompt :prompt-args prompt-args :action action :action-args action-args :historyp historyp :api-options api-options :never-stream-p never-stream-p) + + (let* ((prompt-args-with-arg (map-merge 'plist prompt-args `(:arg ,arg))) + (prompt-result (if (functionp prompt) (apply prompt prompt-args-with-arg) (format "%s" prompt))) + (basic-prompt (robby--format-prompt prompt-result robby-prompt-spec-fn)) + (request-input (robby--request-input basic-prompt historyp robby--history robby-chat-system-message)) + (payload (append request-input (robby--get-request-input api-options))) + (response-buffer (get-buffer-create (robby--get-response-buffer action action-args))) + (response-region (robby--get-response-region response-buffer)) + (streamp (robby--get-stream-p :never-stream-p never-stream-p :no-op-pattern no-op-pattern :grounding-fns grounding-fns)) + (text-processed "")) + + (robby--log (format "# Request body alist:\n%s\n" payload)) + + (with-undo-amalgamate + (with-current-buffer response-buffer + (robby-kill-last-process t) + (robby--spinner-start) + (setq robby--last-process + (condition-case curl-err + (robby--request + :payload payload + :streamp streamp + :on-text + (cl-function + (lambda (&key text completep) + (when (buffer-live-p response-buffer) + (condition-case err + (with-current-buffer response-buffer + (robby--handle-text + :action action + :action-args action-args + :arg arg + :basic-prompt basic-prompt + :chars-processed (length text-processed) + :completep completep + :grounding-fns grounding-fns + :no-op-pattern no-op-pattern + :no-op-message no-op-message + :response-buffer response-buffer + :response-region response-region + :text text + :text-processed text-processed)) + (error (robby--handle-error err)))) + (setq text-processed (concat text text-processed)))) + :on-error + (lambda (err) + (with-current-buffer response-buffer + (robby--handle-error err)))) + (error (robby--handle-error curl-err)))))))) +(cl-defun robby-run-command (&key arg prompt prompt-args action action-args api-options grounding-fns no-op-pattern no-op-message historyp never-stream-p) + "Run a command using OpenAI. + +ARG is the interactive prefix arg, if any. It is passed to the +PROMPT and ACTION functions. + +PROMPT is a string or a function. If a string it used as is as +the prompt to send to OpenAI. If PROMPT is a function it is +called with PROMPT-ARGS to produce the prompt. PROMPT-ARGS is a +key / value style property list. + +When the response text is received from OpenAI, ACTION is called +with the property list ACTION-ARGS and `:text', where text +is the text response from OpenAI. + +API-OPTIONS is an optional property list of options to pass to +the OpenAI API. Kebab case keys are converted to snake case JSON +keys. For example `max-tokens' becomes \"max_tokens\". The +values in API-OPTIONS are merged with and overwrite equivalent +values in the customization options specified in for example +`robby-chat-options' or `robby-completion-options'. + +GROUNDING-FNS - Format the response from OpenAI before returning +it. Only used if `NEVER-STREAM-P' is t. + +NO-OP-PATTERN - If the response matches this regular expression, +do not perform the action. Useful with a prompt that tells OpenAI +to respond with a certain response if there is nothing to do. For +example with a prompt of \"Fix this code. Respond with \\='the code +is correct\\=' if the code is correct\", then a NO-OP-PATTERN of +\"code is correct\" will tell robby to not replace the region +when the pattern matches. Only use NO-OP-PATTERN when +NEVER-STREAM-P is t. + +NO-OP-MESSAGE - Message to display when NO-OP-PATTERN matches. Optional. + +HISTORYP indicates whether or not to use conversation history. + NEVER-STREAM-P - Never stream response if t. If present this value overrides the `robby-stream' customization variable." ;; save command history diff --git a/robby-togetherai-provider.el b/robby-togetherai-provider.el new file mode 100644 index 0000000..9aff8c6 --- /dev/null +++ b/robby-togetherai-provider.el @@ -0,0 +1,22 @@ +;;; robby-togetherai-provider.el --- robby togetherai provider -*- lexical-binding:t -*- + +;;; Code: + +(require 'robby-provider) + +(require 'cl-generic) + +(robby-add-provider + :symbol 'togetherai + :name "Together AI" + :host "api.together.xyz" + :default-model "togethercomputer/StripedHyena-Nous-7B" + :models-path "/models/info?=") + +(cl-defmethod robby-providers-parse-models (data &context (robby-provider (eql 'togetherai))) + "Get models from response DATA for togetherai." + (seq-map (lambda (elem) (alist-get 'name elem)) data)) + +(provide 'robby-togetherai-provider) + +;;; robby-togetherai-provider.el ends here diff --git a/robby.el b/robby.el index ec621ad..12a65dd 100644 --- a/robby.el +++ b/robby.el @@ -31,6 +31,10 @@ (require 'seq) (require 'transient)) +;; require providers +(require 'robby-openai-provider) +(require 'robby-togetherai-provider) + ;; require files with autoloads, and customization file (require 'robby-customization) (require 'robby-commands) diff --git a/test/robby-providers-test.el b/test/robby-providers-test.el deleted file mode 100644 index abdd031..0000000 --- a/test/robby-providers-test.el +++ /dev/null @@ -1,26 +0,0 @@ -;;; robby--providers-test.el --- tests for robby provider functions and methods -*- lexical-binding:t -*- - -(require 'ert) - -(require 'robby-providers) - -;;; robby--providers-parse-error tests -(ert-deftest robby--providers-parse-error-standard () - (let ((robby-provider 'openai)) - (should (equal (robby--providers-parse-error '((error . ((message . "whoops"))))) - "whoops")))) - -(ert-deftest robby--providers-parse-error-standard-no-error () - (let ((robby-provider 'openai)) - (should (eql (robby--providers-parse-error '((not-error . ((message . "whoops"))))) - nil)))) - -(ert-deftest robby--providers-parse-error-mistral () - (let ((robby-provider 'mistral)) - (should (equal (robby--providers-parse-error '((object . "error") (message . "whoops"))) - "whoops")))) - -(ert-deftest robby--providers-parse-error-mistral-no-error () - (let ((robby-provider 'mistral)) - (should (eql (robby--providers-parse-error '((object . "not-error") (message . "whoops"))) - nil)))) From 945da9e0d1e63e0689a75cc9fdc40fb5d1fc59c6 Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Sun, 21 Apr 2024 06:56:28 -0500 Subject: [PATCH 18/28] fix robby--provider-type customization helper, clean up cruft --- robby-customization.el | 2 +- robby-provider.el | 12 +----------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/robby-customization.el b/robby-customization.el index 19a3dd0..0400062 100644 --- a/robby-customization.el +++ b/robby-customization.el @@ -118,7 +118,7 @@ Includes a choice for each provider added via `(choice ,@(seq-map (lambda (provider-settings) - `(const :tag ,(plist-get (cdr robby--provider-settings) :name) ,(car provider-settings))) + `(const :tag ,(plist-get (cdr provider-settings) :name) ,(car provider-settings))) robby--provider-settings))) (defcustom robby-provider 'openai diff --git a/robby-provider.el b/robby-provider.el index 576afa8..0808d9e 100644 --- a/robby-provider.el +++ b/robby-provider.el @@ -11,16 +11,6 @@ (defvar robby--provider-settings nil "alist of provider settings.") -;; TODO use this function for each provider upon initialization -(defun robby--add-provider-choice (symbol name) - "Add a provider SYMBOL and NAME to the choices for the -`robby-provider' custom type." - (let* ((new-choice `(const :tag ,name ,symbol)) - (old-choices (cdr (get 'provider 'custom-type))) - (new-choices (cons new-choice old-choices)) - (new-type (cons 'choice new-choices))) - (put 'provider 'custom-type new-type))) - (defun robby--get-provider-settings () "Return the settings of the current provider." (alist-get robby-provider robby--provider-settings)) @@ -48,7 +38,7 @@ :default-model ,default-model :api-base-path ,(or api-base-path "/v1/chat/completions") :models-path ,(or models-path "/v1/models")))) - (push (cons symbol settings) robby--provider-settings))) + (add-to-list 'robby--provider-settings (cons symbol settings)))) (cl-defmethod robby-provider-parse-error (data) "Get error from response DATA. From 173044124ebe34aae423a756faf53dcf468ac064 Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Sun, 21 Apr 2024 07:00:49 -0500 Subject: [PATCH 19/28] replace condition-case with ignore-errors in one instance --- robby-request.el | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/robby-request.el b/robby-request.el index 7b1ba8e..24863b7 100644 --- a/robby-request.el +++ b/robby-request.el @@ -21,9 +21,8 @@ ;;; request util functions (defun robby--request-parse-error-string (err) "Get error from JSON string ERR." - (condition-case _err - (robby-provider-parse-error (json-read-from-string err)) - (error nil))) + (ignore-errors + (robby-provider-parse-error (json-read-from-string err)))) ;; TODO consider passing url to robby--request (defun robby--chat-url () From 830efe057b9d0528eac163ffb12bbf56bbb55947 Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Sun, 21 Apr 2024 07:42:56 -0500 Subject: [PATCH 20/28] minor cleanups --- robby-customization.el | 2 +- robby-define-command.el | 1 - robby-provider.el | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/robby-customization.el b/robby-customization.el index 0400062..625abdf 100644 --- a/robby-customization.el +++ b/robby-customization.el @@ -10,8 +10,8 @@ (require 'spinner) (require 'robby-api-key) -(require 'robby-validation) (require 'robby-utils) +(require 'robby-validation) ;;; function to validate custom api options (defun robby--validate-custom-api-option (name) diff --git a/robby-define-command.el b/robby-define-command.el index d713324..6557cab 100644 --- a/robby-define-command.el +++ b/robby-define-command.el @@ -8,7 +8,6 @@ (require 'robby-run-command) - (cl-defmacro robby-define-command (name docstring &key diff --git a/robby-provider.el b/robby-provider.el index 0808d9e..d95bbdf 100644 --- a/robby-provider.el +++ b/robby-provider.el @@ -9,7 +9,7 @@ (defvar robby-provider) (defvar robby--provider-settings nil - "alist of provider settings.") + "Global alist of provider settings.") (defun robby--get-provider-settings () "Return the settings of the current provider." @@ -40,7 +40,7 @@ :models-path ,(or models-path "/v1/models")))) (add-to-list 'robby--provider-settings (cons symbol settings)))) -(cl-defmethod robby-provider-parse-error (data) +(cl-defmethod robby-provider-parse-error (_data) "Get error from response DATA. DATA is an alist of the JSON parsed response from the provider." From 894929dea28fde52062e532a6e9d8a6fb608c1b8 Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Sun, 21 Apr 2024 07:43:09 -0500 Subject: [PATCH 21/28] remove duplicate robby-run-command definition --- robby-run-command.el | 93 -------------------------------------------- 1 file changed, 93 deletions(-) diff --git a/robby-run-command.el b/robby-run-command.el index 7a0a3c2..08d8b15 100644 --- a/robby-run-command.el +++ b/robby-run-command.el @@ -13,7 +13,6 @@ (require 'robby-customization) (require 'robby-history) (require 'robby-logging) -(require 'robby-provider) (require 'robby-process) (require 'robby-spinner) @@ -274,98 +273,6 @@ NO-OP-MESSAGE - Message to display when NO-OP-PATTERN matches. Optional. HISTORYP indicates whether or not to use conversation history. -NEVER-STREAM-P - Never stream response if t. If present this -value overrides the `robby-stream' customization variable." - ;; save command history - (robby--save-last-command-options - :prompt prompt :prompt-args prompt-args :action action :action-args action-args :historyp historyp :api-options api-options :never-stream-p never-stream-p) - - (let* ((prompt-args-with-arg (map-merge 'plist prompt-args `(:arg ,arg))) - (prompt-result (if (functionp prompt) (apply prompt prompt-args-with-arg) (format "%s" prompt))) - (basic-prompt (robby--format-prompt prompt-result robby-prompt-spec-fn)) - (request-input (robby--request-input basic-prompt historyp robby--history robby-chat-system-message)) - (payload (append request-input (robby--get-request-input api-options))) - (response-buffer (get-buffer-create (robby--get-response-buffer action action-args))) - (response-region (robby--get-response-region response-buffer)) - (streamp (robby--get-stream-p :never-stream-p never-stream-p :no-op-pattern no-op-pattern :grounding-fns grounding-fns)) - (text-processed "")) - - (robby--log (format "# Request body alist:\n%s\n" payload)) - - (with-undo-amalgamate - (with-current-buffer response-buffer - (robby-kill-last-process t) - (robby--spinner-start) - (setq robby--last-process - (condition-case curl-err - (robby--request - :payload payload - :streamp streamp - :on-text - (cl-function - (lambda (&key text completep) - (when (buffer-live-p response-buffer) - (condition-case err - (with-current-buffer response-buffer - (robby--handle-text - :action action - :action-args action-args - :arg arg - :basic-prompt basic-prompt - :chars-processed (length text-processed) - :completep completep - :grounding-fns grounding-fns - :no-op-pattern no-op-pattern - :no-op-message no-op-message - :response-buffer response-buffer - :response-region response-region - :text text - :text-processed text-processed)) - (error (robby--handle-error err)))) - (setq text-processed (concat text text-processed)))) - :on-error - (lambda (err) - (with-current-buffer response-buffer - (robby--handle-error err)))) - (error (robby--handle-error curl-err)))))))) -(cl-defun robby-run-command (&key arg prompt prompt-args action action-args api-options grounding-fns no-op-pattern no-op-message historyp never-stream-p) - "Run a command using OpenAI. - -ARG is the interactive prefix arg, if any. It is passed to the -PROMPT and ACTION functions. - -PROMPT is a string or a function. If a string it used as is as -the prompt to send to OpenAI. If PROMPT is a function it is -called with PROMPT-ARGS to produce the prompt. PROMPT-ARGS is a -key / value style property list. - -When the response text is received from OpenAI, ACTION is called -with the property list ACTION-ARGS and `:text', where text -is the text response from OpenAI. - -API-OPTIONS is an optional property list of options to pass to -the OpenAI API. Kebab case keys are converted to snake case JSON -keys. For example `max-tokens' becomes \"max_tokens\". The -values in API-OPTIONS are merged with and overwrite equivalent -values in the customization options specified in for example -`robby-chat-options' or `robby-completion-options'. - -GROUNDING-FNS - Format the response from OpenAI before returning -it. Only used if `NEVER-STREAM-P' is t. - -NO-OP-PATTERN - If the response matches this regular expression, -do not perform the action. Useful with a prompt that tells OpenAI -to respond with a certain response if there is nothing to do. For -example with a prompt of \"Fix this code. Respond with \\='the code -is correct\\=' if the code is correct\", then a NO-OP-PATTERN of -\"code is correct\" will tell robby to not replace the region -when the pattern matches. Only use NO-OP-PATTERN when -NEVER-STREAM-P is t. - -NO-OP-MESSAGE - Message to display when NO-OP-PATTERN matches. Optional. - -HISTORYP indicates whether or not to use conversation history. - NEVER-STREAM-P - Never stream response if t. If present this value overrides the `robby-stream' customization variable." ;; save command history From 4aa4977fc2e93ad1046b319928818db007f3cc77 Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Sun, 21 Apr 2024 07:43:28 -0500 Subject: [PATCH 22/28] fix compilation error re robby--provider-settings not defined --- robby-request.el | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/robby-request.el b/robby-request.el index 24863b7..68986c5 100644 --- a/robby-request.el +++ b/robby-request.el @@ -12,10 +12,11 @@ (require 'seq) (require 'url-vars) +(require 'robby-provider) ; require first to make sure robby--provider-settings is defined + (require 'robby-api-key) (require 'robby-customization) (require 'robby-logging) -(require 'robby-provider) (require 'robby-utils) ;;; request util functions From 8f1fe1ed87e18ed07d874d3569f513f73d35f03a Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Sun, 21 Apr 2024 20:49:41 -0500 Subject: [PATCH 23/28] check HTTP status on API requests, better error handling --- robby-customization.el | 6 ------ robby-provider.el | 12 ++++++------ robby-request.el | 36 ++++++++++++++++++++++-------------- robby-run-command.el | 2 +- robby-togetherai-provider.el | 2 +- robby-utils.el | 12 ++++++++++++ test/robby-utils-test.el | 24 ++++++++++++++++++++++++ 7 files changed, 66 insertions(+), 28 deletions(-) diff --git a/robby-customization.el b/robby-customization.el index 625abdf..1186198 100644 --- a/robby-customization.el +++ b/robby-customization.el @@ -104,12 +104,6 @@ It should include a `%s' placeholder for the spinner." "Options to pass to the chat API." :group 'robby) -(defun robby--provider-name (provider) - "Format AI provider name from PROVIDER symbol. - -For example `'openai' becomes \"OpenAI\"." - (string-replace "ai" "AI" (capitalize (symbol-name provider)))) - (defun robby--provider-type () "Get the `robby-provider' custom type. diff --git a/robby-provider.el b/robby-provider.el index d95bbdf..25a9388 100644 --- a/robby-provider.el +++ b/robby-provider.el @@ -15,6 +15,10 @@ "Return the settings of the current provider." (alist-get robby-provider robby--provider-settings)) +(defun robby--provider-name () + "Return the name of the current provider." + (plist-get (robby--get-provider-settings) :name)) + (defun robby--provider-host () "Return the host of the current provider." (plist-get (robby--get-provider-settings) :host)) @@ -40,18 +44,14 @@ :models-path ,(or models-path "/v1/models")))) (add-to-list 'robby--provider-settings (cons symbol settings)))) -(cl-defmethod robby-provider-parse-error (_data) +(cl-defmethod robby-provider-parse-error (data) "Get error from response DATA. DATA is an alist of the JSON parsed response from the provider." - ;; TODO - nil - ;; (cdr (assoc 'message (assoc 'error data))) - ) + (cdr (assoc 'message (assoc 'error data)))) (cl-defmethod robby-provider-parse-models (data) "Get models from response DATA." - (message "base method data: %S" data) (seq-map (lambda (obj) (cdr (assoc 'id obj))) (cdr (assoc 'data data)))) (provide 'robby-provider) diff --git a/robby-request.el b/robby-request.el index 68986c5..14c6551 100644 --- a/robby-request.el +++ b/robby-request.el @@ -1,18 +1,11 @@ ;;; robby-request.el --- Make robby requests via curl or url-retrieve -*- lexical-binding:t -*- -;;; Commentary: - -;; Provides the `robby--request' function to make requests to the OpenAI API. - -;;; Code: - -(require 'cl-lib) (require 'files) (require 'json) (require 'seq) (require 'url-vars) -(require 'robby-provider) ; require first to make sure robby--provider-settings is defined +(require 'robby-provider) ; require first to make sure robby--provider-settings is defined (require 'robby-api-key) (require 'robby-customization) @@ -20,10 +13,26 @@ (require 'robby-utils) ;;; request util functions -(defun robby--request-parse-error-string (err) +(defun robby--request-parse-error-string (string) "Get error from JSON string ERR." (ignore-errors - (robby-provider-parse-error (json-read-from-string err)))) + (robby-provider-parse-error (json-read-from-string string)))) + +(defun robby--request-get-error (string) + "Get error from response STRING, or nil if no error. + +If there is a response status and it is not 200, try to parse the +error message from the response and return that, otherwise return +a generic error message. Otherwise return nil (no error)." + (let ((provider (robby--provider-name)) + (status (robby--parse-http-status string))) + (if (and (numberp status) (not (eq status 200))) + (let ((error-msg (robby--request-parse-error-string string))) + (if error-msg + (format "%s API returned error - '%s'" provider error-msg) + (if (numberp status) + (format "Unexpected response status %S from %s API request" status provider) + (format "Unexpected response from %S API request: %S" provider string))))))) ;; TODO consider passing url to robby--request (defun robby--chat-url () @@ -36,6 +45,7 @@ "--disable" "--silent" "-m 600" + "-w HTTP STATUS: %{http_code}\n" "-H" "Content-Type: application/json")) (defun robby--curl-parse-chunk (remaining data) @@ -103,7 +113,6 @@ STREAMP is non-nil if the response is a stream." (robby--chat-url) curl-options) (error (funcall on-error err))))) - (robby--log (format "# Curl request command:\ncurl %s %s\n" (robby--chat-url) (string-join curl-options " "))) (let ((remaining "") (text "") (errored nil)) @@ -113,9 +122,8 @@ STREAMP is non-nil if the response is a stream." proc (lambda (proc string) (robby--log (format "# Raw curl response chunk:\n%s\n" string)) - (robby--log (format "# proc %S" proc)) (condition-case err - (let ((error-msg (robby--request-parse-error-string string))) + (let ((error-msg (robby--request-get-error string))) (if error-msg (progn (setq errored t) @@ -134,7 +142,7 @@ STREAMP is non-nil if the response is a stream." (funcall on-text :text text :completep t)) (with-current-buffer proc-buffer (let* ((string (buffer-string)) - (error-msg (robby--request-parse-error-string string))) + (error-msg (robby--request-get-error string))) (if error-msg (funcall on-error error-msg) (let ((resp (robby--curl-parse-response string "" nil))) diff --git a/robby-run-command.el b/robby-run-command.el index 08d8b15..484d2db 100644 --- a/robby-run-command.el +++ b/robby-run-command.el @@ -182,7 +182,7 @@ RESPONSE-BUFFER." "Handle an error ERR from OpenAI." (robby--spinner-stop) (let* ((err-msg (if (stringp err) err (error-message-string err))) - (log-msg (format "Error processing robby request: %s\n" err-msg))) + (log-msg (format "Error running robby command: %s" err-msg))) (robby--log log-msg) (message log-msg)) (when (process-live-p robby--last-process) diff --git a/robby-togetherai-provider.el b/robby-togetherai-provider.el index 9aff8c6..8b6e8bb 100644 --- a/robby-togetherai-provider.el +++ b/robby-togetherai-provider.el @@ -10,7 +10,7 @@ :symbol 'togetherai :name "Together AI" :host "api.together.xyz" - :default-model "togethercomputer/StripedHyena-Nous-7B" + ;; :default-model "togethercomputer/StripedHyena-Nous-7B" :models-path "/models/info?=") (cl-defmethod robby-providers-parse-models (data &context (robby-provider (eql 'togetherai))) diff --git a/robby-utils.el b/robby-utils.el index 93b2405..474c4d3 100644 --- a/robby-utils.el +++ b/robby-utils.el @@ -233,6 +233,18 @@ details." response) (funcall grounding-fns response))) +;;; request utils +(defun robby--parse-http-status (resp) + "Parse http status code from response text. + +Use with the curl option --write-out 'HTTP STATUS: %{http_code}\n'. +Returns the status code as a number, or nil if no status code found." + (if (string-match "HTTP STATUS: \\([0-9]+\\)" resp) + (when-let ((m (match-string 1 resp)) + (n (string-to-number m))) + (if (eq n 0) nil n)) + nil)) + (provide 'robby-utils) ;;; robby-utils.el ends here diff --git a/test/robby-utils-test.el b/test/robby-utils-test.el index 4f8b0cc..8389092 100644 --- a/test/robby-utils-test.el +++ b/test/robby-utils-test.el @@ -153,6 +153,30 @@ (should (equal (robby--ground-response "response" #'upcase) "RESPONSE")))) +;;; request utils +(ert-deftest robby--parse-http-status () + (let ((resp "logprobs\":null,\"finish_reason\":\"stop\"}]} + +data: [DONE] + + HTTP STATUS: 200 + +")) + (should (equal + (robby--parse-http-status resp) + 200)))) + +(ert-deftest robby--parse-http-status-no-status () + (let ((chunk "data: {\"id\":\"chatcmpl-9GR46WElbBCZQwFD2WoGeYKnd8I5z\",\"object\":\"chat.completion.chunk\",\"created\":1713704314,\"model\":\"gpt-3.5-turbo-0125\",\"system_fingerprint\":\"fp_c2295e73ad\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}]} + +data: {\"id\":\"chatcmpl-9GR46WElbBCZQwFD2WoGeYKnd8I5z\",\"object\":\"chat.completion.chunk\",\"created\":1713704314,\"model\":\"gpt-3.5-turbo-0125\",\"system_fingerprint\":\"fp_c2295e73ad\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}]} + +data: {\"id\":\"chatcmpl-9GR46WElbBCZQwFD2WoGeYKnd8I5z\",\"object\":\"chat.completion.chunk\",\"created\":1713704314,\"model\":\"gpt-3.5-turbo-0125\",\"system_fingerprint\":\"fp_c2295e73ad\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}]} + + +")) + (should (null (robby--parse-http-status chunk))))) + (provide 'robby-utils-test) ;;; robby-utils-test.el ends here From f6fc59fa07935ed1d78a0dc032ec338ddf49b6fe Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Mon, 22 Apr 2024 06:58:30 -0500 Subject: [PATCH 24/28] fix missing robby--provider-settings in tests --- Makefile | 1 - robby-customization.el | 3 +++ test/robby-test-env.el | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 3b8e5a3..caf8730 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,6 @@ test: install -l ./test/robby-grounding-fns-test.el \ -l ./test/robby-history-test.el \ -l ./test/robby-logging-test.el \ - -l ./test/robby-providers-test.el \ -l ./test/robby-request-test.el \ -l ./test/robby-test-env.el \ -l ./test/robby-utils-test.el \ diff --git a/robby-customization.el b/robby-customization.el index 1186198..eeb7738 100644 --- a/robby-customization.el +++ b/robby-customization.el @@ -13,6 +13,9 @@ (require 'robby-utils) (require 'robby-validation) +;;; Variable declarations +(defvar robby--provider-settings) ; defined in robby-provider.el + ;;; function to validate custom api options (defun robby--validate-custom-api-option (name) "Validate that option NAME is within its allowed range of values. diff --git a/test/robby-test-env.el b/test/robby-test-env.el index 0e04351..1953083 100644 --- a/test/robby-test-env.el +++ b/test/robby-test-env.el @@ -2,6 +2,8 @@ (require 'ert) +(require 'robby-provider) + ;;; Code: (setq package-lint-main-file "/Users/stephenmolitor/repos/robby/robby.el") From a0ff2e09ea4aca18e92c7c0527797a89a7f467bb Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Thu, 25 Apr 2024 20:59:37 -0500 Subject: [PATCH 25/28] Add API error handling and tests --- ...streaming-response-complete-status-200.txt | 16 +++++++++ ...streaming-response-complete-status-400.txt | 17 ++++++++++ ...eaming-response-openai-model-not-found.txt | 12 +++++++ ...ng-response-togetherai-model-not-found.txt | 1 + robby-request.el | 6 ++-- test/robby-request-test.el | 34 ++++++++++++++++++- 6 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 fixtures/streaming-response-complete-status-200.txt create mode 100644 fixtures/streaming-response-complete-status-400.txt create mode 100644 fixtures/streaming-response-openai-model-not-found.txt create mode 100644 fixtures/streaming-response-togetherai-model-not-found.txt diff --git a/fixtures/streaming-response-complete-status-200.txt b/fixtures/streaming-response-complete-status-200.txt new file mode 100644 index 0000000..d7916ec --- /dev/null +++ b/fixtures/streaming-response-complete-status-200.txt @@ -0,0 +1,16 @@ +data: +{"id":"chatcmpl-a","object":"chat.completion.chunk","created":1689279638,"model":"gpt-3.5-turbo-0613","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} + +data: +{"id":"chatcmpl-a","object":"chat.completion.chunk","created":1689279638,"model":"gpt-3.5-turbo-0613","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]} + +data: +{"id":"chatcmpl-7bx5i7z5CscbA6mQgHGJ2qlXiv15s","object":"chat.completion.chunk","created":1689279638,"model":"gpt-3.5-turbo-0613","choices":[{"index":0,"delta":{"content":" there!"},"finish_reason":null}]} + +data: +{"id":"chatcmpl-7bx5i7z5CscbA6mQgHGJ2qlXiv15s","object":"chat.completion.chunk","created":1689279638,"model":"gpt-3.5-turbo-0613","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: +[DONE] + +HTTP STATUS: 200 diff --git a/fixtures/streaming-response-complete-status-400.txt b/fixtures/streaming-response-complete-status-400.txt new file mode 100644 index 0000000..47cbd33 --- /dev/null +++ b/fixtures/streaming-response-complete-status-400.txt @@ -0,0 +1,17 @@ +data: +{"id":"chatcmpl-a","object":"chat.completion.chunk","created":1689279638,"model":"gpt-3.5-turbo-0613","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} + +data: +{"id":"chatcmpl-a","object":"chat.completion.chunk","created":1689279638,"model":"gpt-3.5-turbo-0613","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]} + +data: +{"id":"chatcmpl-7bx5i7z5CscbA6mQgHGJ2qlXiv15s","object":"chat.completion.chunk","created":1689279638,"model":"gpt-3.5-turbo-0613","choices":[{"index":0,"delta":{"content":" there!"},"finish_reason":null}]} + +data: +{"id":"chatcmpl-7bx5i7z5CscbA6mQgHGJ2qlXiv15s","object":"chat.completion.chunk","created":1689279638,"model":"gpt-3.5-turbo-0613","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: +[DONE] + +HTTP STATUS: 400 + diff --git a/fixtures/streaming-response-openai-model-not-found.txt b/fixtures/streaming-response-openai-model-not-found.txt new file mode 100644 index 0000000..dfdb8c7 --- /dev/null +++ b/fixtures/streaming-response-openai-model-not-found.txt @@ -0,0 +1,12 @@ +{ + "error": { + "message": "The model `adfasdf` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + + HTTP STATUS: 404 + + diff --git a/fixtures/streaming-response-togetherai-model-not-found.txt b/fixtures/streaming-response-togetherai-model-not-found.txt new file mode 100644 index 0000000..00b15e3 --- /dev/null +++ b/fixtures/streaming-response-togetherai-model-not-found.txt @@ -0,0 +1 @@ +{"error":{"message":"Unable to access model adfasdf. Please visit https://api.together.xyz to see the list of supported models or contact the owner to request access.","type":"invalid_request_error","param":null,"code":"model_not_found"}} HTTP STATUS: 404 diff --git a/robby-request.el b/robby-request.el index 14c6551..c4e7a04 100644 --- a/robby-request.el +++ b/robby-request.el @@ -21,15 +21,15 @@ (defun robby--request-get-error (string) "Get error from response STRING, or nil if no error. -If there is a response status and it is not 200, try to parse the +If there is a status code and it is not 200, try to parse the error message from the response and return that, otherwise return -a generic error message. Otherwise return nil (no error)." +a generic error message. If status is 200 return nil (no error)." (let ((provider (robby--provider-name)) (status (robby--parse-http-status string))) (if (and (numberp status) (not (eq status 200))) (let ((error-msg (robby--request-parse-error-string string))) (if error-msg - (format "%s API returned error - '%s'" provider error-msg) + (format "%s API error - '%s'" provider error-msg) (if (numberp status) (format "Unexpected response status %S from %s API request" status provider) (format "Unexpected response from %S API request: %S" provider string))))))) diff --git a/test/robby-request-test.el b/test/robby-request-test.el index 7979fd7..66f244a 100644 --- a/test/robby-request-test.el +++ b/test/robby-request-test.el @@ -3,6 +3,8 @@ (require 'ert) (require 'robby-request) +(require 'robby-openai-provider) +(require 'robby-togetherai-provider) ;;; Code: @@ -10,7 +12,7 @@ "Return filepath's file content." (with-temp-buffer (insert-file-contents filepath) - (buffer-string))) + (buffer-string))) (ert-deftest robby--curl-parse-response--streaming-response () (let ((parsed (robby--curl-parse-response (robby--read-file-into-string "./fixtures/streaming-response-complete.txt") "" t))) @@ -26,6 +28,36 @@ (let ((part2 (robby--curl-parse-response (robby--read-file-into-string "./fixtures/streaming-response-incomplete-part-2.txt") "{\"id\":\"chatcmpl-a\",\"object\":\"chat.completion.chu" t))) (should (equal (plist-get part2 :text) " there!")))) +(ert-deftest robby--request-get-error--no-error-on-200 () + (dolist (provider '(openai togetherai)) + (let ((robby-provider provider) + (resp-string (robby--read-file-into-string "./fixtures/streaming-response-complete-status-200.txt"))) + (should (equal (robby--parse-http-status resp-string) 200)) + (should (null (robby--request-get-error resp-string)))))) + +(ert-deftest robby--request-get-error--openai-error () + (let ((robby-provider 'openai) + (resp-string (robby--read-file-into-string "./fixtures/streaming-response-openai-model-not-found.txt"))) + (should (equal (robby--parse-http-status resp-string) 404)) + (should (equal (robby--request-get-error resp-string) + "OpenAI API error - 'The model `adfasdf` does not exist or you do not have access to it.'")))) + +(ert-deftest robby--request-get-error--together-error () + (let ((robby-provider 'togetherai) + (resp-string (robby--read-file-into-string "./fixtures/streaming-response-togetherai-model-not-found.txt"))) + (should (equal (robby--parse-http-status resp-string) 404)) + (should (equal (robby--request-get-error resp-string) + "Together AI API error - 'Unable to access model adfasdf. Please visit https://api.together.xyz to see the list of supported models or contact the owner to request access.'")))) + +(ert-deftest robby--request-get-error--generic-error-when-400-no-error-message () + (dolist (provider '(openai togetherai)) + (let ((robby-provider provider) + (resp-string (robby--read-file-into-string "./fixtures/streaming-response-complete-status-400.txt"))) + (should (equal (robby--parse-http-status resp-string) 400)) + (should (string-match-p + "Unexpected response status 400 from .+ API request" + (robby--request-get-error resp-string)))))) + (provide 'robby-request-test) ;;; robby-request-test.el ends here From abf77139435abb429deba6136aa0e6a517880e66 Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Fri, 26 Apr 2024 08:16:09 -0500 Subject: [PATCH 26/28] Add Mistral AI Provider to Robby --- robby-mistralai-provider.el | 26 ++++++++++++++++++++++++++ robby.el | 1 + 2 files changed, 27 insertions(+) create mode 100644 robby-mistralai-provider.el diff --git a/robby-mistralai-provider.el b/robby-mistralai-provider.el new file mode 100644 index 0000000..258fadb --- /dev/null +++ b/robby-mistralai-provider.el @@ -0,0 +1,26 @@ +;;; robby-mistralai-provider.el --- robby mistralai provider -*- lexical-binding:t -*- + +;;; Code: + +(require 'robby-provider) + +(require 'cl-generic) + +(robby-add-provider + :symbol 'mistralai + :name "Mistral AI" + :host "api.mistral.ai" + :default-model "mistral-small" + :models-path "/v1/models") + +;; example: +;; ((object . "error") (message . "Invalid model: gpt-4-turbo-preview") (type . "invalid_model") (param) (code . "1500")) +(cl-defmethod robby-provider-parse-error :around (data &context (robby-provider (eql 'mistralai))) + "Parse mistralai error from response DATA." + (let ((object (alist-get 'object data))) + (when (and (stringp "error") (string= object "error")) + (alist-get 'message data)))) + +(provide 'robby-mistralai-provider) + +;;; robby-mistralai-provider.el ends here diff --git a/robby.el b/robby.el index 12a65dd..0d301d7 100644 --- a/robby.el +++ b/robby.el @@ -32,6 +32,7 @@ (require 'transient)) ;; require providers +(require 'robby-mistralai-provider) (require 'robby-openai-provider) (require 'robby-togetherai-provider) From b2e1750c70f48ac164af813083a589b5944a465d Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Fri, 26 Apr 2024 08:16:26 -0500 Subject: [PATCH 27/28] Remove unused robby--models-url function --- robby-models.el | 4 ---- 1 file changed, 4 deletions(-) diff --git a/robby-models.el b/robby-models.el index 1b123ba..8f90e97 100644 --- a/robby-models.el +++ b/robby-models.el @@ -13,10 +13,6 @@ (defvar robby--models nil) -(defun robby--models-url () - "Get the URL for the models endpoint." - (concat "https://" (robby--provider-host) (robby--provider-models-path))) - (defun robby--get-models () "Get the list of available models from OpenAI. From c5c8fb327b4487e1e81e0d6a043a3de2dae60571 Mon Sep 17 00:00:00 2001 From: Steve Molitor Date: Fri, 17 May 2024 16:52:35 -0500 Subject: [PATCH 28/28] Fix bug fetching models --- robby-models.el | 4 ++-- robby-togetherai-provider.el | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/robby-models.el b/robby-models.el index 8f90e97..122c0dd 100644 --- a/robby-models.el +++ b/robby-models.el @@ -16,12 +16,12 @@ (defun robby--get-models () "Get the list of available models from OpenAI. -Make request to OpenAI API to get the list of available models." +Make request to OpenAI API to get the list of available models." (if robby--models robby--models (let* ((inhibit-message t) (message-log-max nil) - (url (robby--models-url)) + (url (concat "https://" (robby--provider-host) (robby--provider-models-path))) (url-request-method "GET") (url-request-extra-headers `(("Content-Type" . "application/json") diff --git a/robby-togetherai-provider.el b/robby-togetherai-provider.el index 8b6e8bb..6cf6ac8 100644 --- a/robby-togetherai-provider.el +++ b/robby-togetherai-provider.el @@ -13,7 +13,7 @@ ;; :default-model "togethercomputer/StripedHyena-Nous-7B" :models-path "/models/info?=") -(cl-defmethod robby-providers-parse-models (data &context (robby-provider (eql 'togetherai))) +(cl-defmethod robby-provider-parse-models (data &context (robby-provider (eql 'togetherai))) "Get models from response DATA for togetherai." (seq-map (lambda (elem) (alist-get 'name elem)) data))