branch: elpa/gptel
commit 404cf337b72bc29c6ed37b0c95b5390b8dd4e69e
Author: Felipe Ochoa <fel...@incquery.com>
Commit: Karthik Chikmagalur <karthikchikmaga...@gmail.com>

    gptel-bedrock: Add gptel-bedrock.el backend for AWS Bedrock
    
    * gptel-bedrock.el (gptel-bedrock, gptel-bedrock--prompt-type)
    (gptel--request-data, gptel--parse-tools, gptel--parse-response)
    (gptel-bedrock--record-tool-use, gptel--parse-list)
    (gptel--wrap-user-prompt, gptel-bedrock--inject-media-context)
    (gptel-bedrock--inject-text-context, gptel-bedrock--stream-cursor)
    (gptel-curl--parse-stream, gptel-bedrock--parse-stream-message)
    (gptel-bedrock--parse-headers, gptel-bedrock--bytes-to-int16)
    (gptel-bedrock--bytes-to-int32, gptel-bedrock--bytes-to-int64)
    (gptel-bedrock--bytes-to-uuid)
    (gptel-bedrock--assemble-content-blocks, gptel--parse-buffer)
    (gptel-bedrock--image-formats, gptel-bedrock--doc-formats)
    (gptel-bedrock--parse-multipart, gptel--parse-tool-results)
    (gptel-bedrock--fetch-aws-profile-credentials)
    (gptel-bedrock--get-credentials, gptel-bedrock-model-ids)
    (gptel-bedrock--get-model-id):
    Add gptel-bedrock.el to support AWS Bedrock models.
---
 gptel-bedrock.el | 587 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 587 insertions(+)

diff --git a/gptel-bedrock.el b/gptel-bedrock.el
new file mode 100644
index 0000000000..3fab6d3ead
--- /dev/null
+++ b/gptel-bedrock.el
@@ -0,0 +1,587 @@
+;;; gptel-bedrock.el --- AWS Bedrock support for gptel  -*- lexical-binding: 
t; -*-
+
+;; Copyright (C) 2025 Karthik Chikmagalur
+
+;; Keywords: comm, convenience
+
+;; This program is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file adds support for AWS Bedrock to gptel.  Documentation for the 
request data and the
+;; response payloads can be found at these two links:
+;; * 
https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html
+;; * 
https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ConverseStream.html
+
+;;; Code:
+(require 'cl-generic)
+(require 'map)
+(require 'gptel)
+(require 'gptel-anthropic)
+(require 'mail-parse)
+
+(cl-defstruct (gptel-bedrock (:constructor gptel--make-bedrock)
+                             (:copier nil)
+                             (:include gptel-backend)))
+
+(defconst gptel-bedrock--prompt-type
+  ;; For documentation purposes only -- this describes the type of prompt 
objects that get passed
+  ;; around. 
https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Message.html
+  '(plist
+    :role (member "user" "assistant")
+    :content (array (or (plist :text string)
+                        (plist :image (:format (member "png" "jpeg" "gif" 
"webp")
+                                       :source (plist :bytes string))) ; bytes 
is b64 encoded
+                        (plist :document (:format (member "pdf" "csv" "doc" 
"docx" "xls" "xlsx" "html" "txt" "md")
+                                          :name string
+                                          :source (plist :bytes string))) ; 
bytes is b64 encoded
+                        (plist :toolUse (plist :input any :name string 
:toolUseId string))
+                        (plist :toolResult (plist
+                                            :toolUseId string
+                                            :status (member "success" "error")
+                                            ;; AWS allows more result types in
+                                            ;; ToolResultContentBlock, but we 
only send text results
+                                            :content (array (plist :text 
string))))))))
+
+(cl-defmethod gptel--request-data ((backend gptel-bedrock) prompts)
+  "Prepare request data for AWS Bedrock in converse format from PROMPTS."
+  (nconc
+   `(:messages [,@prompts] :inferenceConfig (:maxTokens ,(or gptel-max-tokens 
500)))
+   (when gptel--system-message `(:system [(:text ,gptel--system-message)]))
+   (when gptel-temperature `(:temperature ,gptel-temperature))
+   (when (and gptel-use-tools gptel-tools)
+     `(:toolConfig (:toolChoice ,(if (eq gptel-use-tools 'force) '(:any '()) 
'(:auto '()))
+                    :tools ,(gptel--parse-tools backend gptel-tools))))))
+
+(cl-defmethod gptel--parse-tools ((_backend gptel-bedrock) tools)
+  "Parse TOOLS and return a list of ToolSpecification objects.
+
+TOOLS is a list of `gptel-tool' structs, which see."
+   ;; 
https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolSpecification.html
+   (let ((default-outputs (cl-call-next-method))) ;; use openai tool-parse
+     (map 'vector
+      (lambda (tool spec)
+        (list :toolSpec
+              (list
+               :name (gptel-tool-name tool)
+               :description (gptel-tool-description tool)
+               :inputSchema (list :json (plist-get (plist-get spec :function) 
:parameters)))))
+      (ensure-list tools) default-outputs)))
+
+(cl-defmethod gptel--parse-response ((_backend gptel-bedrock) response info)
+  "Parse a Bedrock (non-streaming) RESPONSE and return response text.
+
+Mutate state INFO with response metadata."
+  (plist-put info :stop-reason (plist-get response :stopReason))
+  (plist-put info :input-tokens
+             (map-nested-elt response '(:usage :inputTokens)))
+  (plist-put info :output-tokens
+             (map-nested-elt response '(:usage :outputTokens)))
+
+  (let* ((message (map-nested-elt response '(:output :message)))
+         (content-strs (thread-last (plist-get message :content)
+                                    (mapcar (lambda (cblock) (plist-get cblock 
:text)))
+                                    (delq nil))))
+    (gptel-bedrock--record-tool-use message info)
+    (and content-strs (apply #'concat content-strs))))
+
+(defun gptel-bedrock--record-tool-use (message info)
+  "If MESSAGE has tool use requests, save those to INFO."
+  (let* ((content (plist-get message :content))
+         (tool-use-blocks (cl-remove-if-not
+                           (lambda (cblock) (plist-get cblock :toolUse))
+                           content)))
+    (when tool-use-blocks
+      (cl-callf vconcat (plist-get (plist-get info :data) :messages) (list 
message))
+
+      (plist-put info :tool-use
+                 (mapcar (lambda (block)
+                           (let ((tool-use (plist-get block :toolUse)))
+                             (list
+                              :name (plist-get tool-use :name)
+                              :args (plist-get tool-use :input)
+                              :id (plist-get tool-use :toolUseId))))
+                         tool-use-blocks)))))
+
+(cl-defmethod gptel--parse-list ((_backend gptel-bedrock) prompt-strings)
+  "Create a list of prompt objects from PROMPT-STRINGS.
+
+Assumes this is a conversation with alternating roles."
+  (cl-loop for text in prompt-strings
+           for role = t then (not role)
+           if text collect
+           (list :role (if role "user" "assistant")
+                 :content `[(:text ,text)])))
+
+(cl-defmethod gptel--wrap-user-prompt ((_backend gptel-bedrock) prompts 
&optional inject-media)
+  "Inject context into a conversation.
+
+PROMPTS is list of prompt objects.  If INJECT-MEDIA is non-nil
+inject the media files from context into the beginning of the
+conversation; otherwise inject the context into the last prompt."
+  (if inject-media
+      (gptel-bedrock--inject-media-context prompts)
+    (gptel-bedrock--inject-text-context prompts)))
+
+(defun gptel-bedrock--inject-media-context (prompts)
+  "Inject media files from context into a conversation.
+Media files will be added at the beginning of the conversation.
+PROMPTS should be a non-empty list of prompt objects."
+  (when-let* ((media-list (gptel-context--collect-media)))
+    (cl-callf2 vconcat (gptel-bedrock--parse-multipart media-list)
+               (plist-get (car prompts) :content))))
+
+(defun gptel-bedrock--inject-text-context (prompts)
+  "Inject text context into the last prompt object from a conversation.
+PROMPTS should be a non-empty list of prompt objects."
+  (cl-assert prompts nil "Expected a non-empty list of prompts")
+  (when-let* ((wrapped (gptel-context--wrap nil)))
+    (cl-callf2 vconcat `[(:text ,wrapped)]
+               (plist-get (car (last prompts)) :content))))
+
+(defvar-local gptel-bedrock--stream-cursor nil
+  "Marker to indicate last point parsed.")
+
+(cl-defmethod gptel-curl--parse-stream ((_backend gptel-bedrock) info)
+  "Parse an AWS Bedrock streaming response from the ConverseStream API.
+INFO is a plist containing the request context."
+  (cl-block fn
+    (save-excursion
+      ;; Each streaming request uses a fresh buffer, so the cursor starts out 
null. We keep it unset
+      ;; until we have received all the headers
+      (when (null gptel-bedrock--stream-cursor)
+        (goto-char (point-min))
+        (unless (search-forward "\r\n\r\n" nil t) (cl-return-from fn))
+        (save-restriction
+          (narrow-to-region (point-min) (point)) ; Required by mail-fetch-field
+          (let ((content-type (mail-header-parse-content-type
+                               (mail-fetch-field "Content-Type"))))
+            (cl-assert content-type nil "No Content-Type header found")
+            (cl-assert (string-equal (car content-type) 
"application/vnd.amazon.eventstream")
+                       t "Unexpected Content-Type %S, expected %S")))
+        (setq gptel-bedrock--stream-cursor (point-marker))
+
+        ;; :accumulated-events contains the events from an in-progress 
message, from the
+        ;; messageStart onwards. With each messageStop it gets cleared
+        (plist-put info :accumulated-events nil))
+
+      ;; Start of main routine
+      (let ((acc-cell (cdr (plist-member info :accumulated-events))) strings 
prompts)
+        (goto-char gptel-bedrock--stream-cursor)
+        (while-let ((event (gptel-bedrock--parse-stream-message)))
+          (let ((event-type (assoc-default ":event-type" (plist-get event 
:headers))))
+            (when (member event-type '("messageStart" "contentBlockStart" 
"contentBlockDelta"
+                                       "contentBlockStop"))
+              (push event (car acc-cell)))
+            (pcase event-type
+              ("metadata"
+               (plist-put info :input-tokens (map-nested-elt event '(:payload 
:usage :inputTokens)))
+               (plist-put info :output-tokens (map-nested-elt event '(:payload 
:usage :outputTokens))))
+              ("contentBlockDelta"
+               (when-let ((delta-text (map-nested-elt event '(:payload :delta 
:text))))
+                 (push delta-text strings)))
+              ("messageStop"
+               (push (gptel-bedrock--assemble-content-blocks (nreverse (car 
acc-cell))) prompts)
+               (setf (car acc-cell) nil)
+               (plist-put info :stop-reason (map-nested-elt event '(:payload 
:stopReason)))
+               (plist-put info :message-complete t)))))
+        (move-marker gptel-bedrock--stream-cursor (point))
+
+        (dolist (message prompts) (gptel-bedrock--record-tool-use message 
info))
+        (apply #'concat (nreverse strings))))))
+
+(defun gptel-bedrock--parse-stream-message ()
+  "Parse AWS Bedrock event-stream message starting at current position.
+Point should be at the beginning of an event in the `vnd.amazon.event-stream'
+format.  Returns plist with :headers and :payload keys if successful, nil if
+incomplete."
+  ;; https://github.com/awslabs/aws-c-event-stream has documentation of this 
format
+  ;; The format consists of three main sections: Prelude, Data, and Message 
CRC.
+  ;; 1. Prelude (12 bytes)
+  ;;    a. Total Byte Length (4 bytes): Specifies the total length of the 
message.
+  ;;    b. Headers Byte Length (4 bytes): Indicates the length of the headers 
section.
+  ;;    c. Prelude CRC (4 bytes): A CRC value for validating the integrity of 
the prelude.
+  ;; 2. Data (variable length)
+  ;;    a. Headers: An array of packed headers. Each header has a specific 
format documented in
+  ;;       gptel-bedrock--parse-headers
+  ;;    b. Payload: The main message content, also of variable length. Length 
can be computed from
+  ;;       the prelude fields by subtracting the prelude length, headers 
length, and message CRC
+  ;;       length from the total.
+  ;; 3. Message CRC (4 bytes): A 4-byte CRC to validate the integrity of the 
entire message.
+
+  ;; (point-max) is the position after the last character, hence the use of >= 
and not > below
+  (when (>= (- (point-max) (point)) 12)
+    (let* ((prelude-start (point))
+           (prelude-length 12)
+           (prelude-end (+ prelude-start prelude-length))
+           (prelude (buffer-substring-no-properties prelude-start prelude-end))
+           (total-length (gptel-bedrock--bytes-to-int32 (substring prelude 0 
4)))
+           (headers-length (gptel-bedrock--bytes-to-int32 (substring prelude 4 
8)))
+           (headers-start prelude-end)
+           (headers-end (+ headers-start headers-length))
+           headers payload)
+      ;; We don't validate either CRC because isn't that what the networking 
stack is for?
+
+      (when (>= (point-max) (+ prelude-start total-length))
+        (goto-char headers-start)
+        (setq headers (gptel-bedrock--parse-headers (buffer-substring (point) 
headers-end)))
+        (cl-assert (equal (assoc-default ":message-type" headers) "event")
+                   t "Unknown message type %S; expected %S")
+        (cl-assert (equal (assoc-default ":content-type" headers) 
"application/json")
+                   t "Unexpected content-type %S is not %S")
+
+        (goto-char headers-end)
+        (setq payload (gptel--json-read))
+        (let* ((message-crc 4)
+               (payload-length (- total-length headers-length prelude-length 
message-crc)))
+          (cl-assert (= (- (point) headers-end) payload-length)
+                     t "Unexpected payload length %d; expected %d."))
+
+        (goto-char (+ prelude-start total-length))
+        `(:headers ,headers :payload ,payload)))))
+
+(defun gptel-bedrock--parse-headers (headers-data)
+  "Parse HEADERS-DATA into alist of (NAME . VALUE).
+Keys are string-valued, lower-cased names."
+  ;; Header wire format:
+  ;;   1. Header Name Byte Length (1 byte): Specifies the length of the header 
name.
+  ;;   2. Header Name (String) (Variable length): Contains the name of the 
header.
+  ;;   3. Header Value Type (1 byte): Identifies the type of the header value.
+  ;;   4. Value String Byte Length (2 bytes): Indicates the length of the 
value string.
+  ;;   5. Value (Variable length): Holds the actual value bytes
+  (let ((pos 0) (max (length headers-data)) headers)
+    (cl-flet ((pos++ () (prog1 pos (cl-incf pos)))
+              (++pos (n) (cl-incf pos n))
+              (utf8 (unibyte-string) (decode-coding-string unibyte-string 
'utf-8 t)))
+      (while (< pos max)
+        (let* ((name-len (aref headers-data (pos++)))
+               (name (substring headers-data pos (++pos name-len)))
+               (type (aref headers-data (pos++)))
+               (value-len (gptel-bedrock--bytes-to-int16 (substring 
headers-data pos (++pos 2))))
+               (value
+                (pcase type
+                  ;; Header types from 
https://awslabs.github.io/aws-crt-python/api/eventstream.html
+                  (0 t)
+                  (1 :json-false)
+                  (2 (let ((res (aref headers-data (pos++)))) ; int8
+                       (if (> res 127) (- res 256) res)))
+                  (3 (gptel-bedrock--bytes-to-int16 (substring headers-data 
pos (++pos 2)))) ; int16
+                  (4 (gptel-bedrock--bytes-to-int32 (substring headers-data 
pos (++pos 4)))) ; int32
+                  (5 (gptel-bedrock--bytes-to-int64 (substring headers-data 
pos (++pos 8)))) ; int64
+                  (6 (substring headers-data pos (++pos value-len)))           
              ; raw bytes
+                  (7 (utf8 (substring headers-data pos (++pos value-len))))    
              ; utf8 string
+                  (8 (decode-time       ; 64 bit int with seconds since the 
Unix epoch
+                      (gptel-bedrock--bytes-to-int64 (substring headers-data 
pos (++pos 8))) t))
+                  (9 (gptel-bedrock--bytes-to-uuid ; 16 byte UUID
+                      (substring headers-data pos (++pos 16))))
+                  (_ (error "Unknown header type: %d" type)))))
+          (push (cons (downcase (utf8 name)) value) headers)))
+      (cl-assert (= pos max) t "Headers did not parse cleanly. pos=%d  
header-len=%d")
+      headers)))
+
+(defun gptel-bedrock--bytes-to-int16 (bytes)
+  "Convert 2-byte string BYTES to big-endian signed integer."
+  (let ((b0 (logand (aref bytes 0) 255))
+        (b1 (logand (aref bytes 1) 255)))
+    (let ((result (+ (ash b0 8) b1)))
+      (if (>= b0 #x80) (- result (ash 1 16)) result))))
+
+(defun gptel-bedrock--bytes-to-int32 (bytes)
+  "Convert 4-byte string BYTES to big-endian signed integer."
+  (let ((b0 (logand (aref bytes 0) 255))
+        (b1 (logand (aref bytes 1) 255))
+        (b2 (logand (aref bytes 2) 255))
+        (b3 (logand (aref bytes 3) 255)))
+    (let ((result (+ (ash b0 24) (ash b1 16) (ash b2 8) b3)))
+      (if (>= b0 #x80) (- result (ash 1 32)) result))))
+
+(defun gptel-bedrock--bytes-to-int64 (bytes)
+  "Convert 8-byte string BYTES to big-endian signed integer."
+  (let ((b0 (logand (aref bytes 0) 255))
+        (b1 (logand (aref bytes 1) 255))
+        (b2 (logand (aref bytes 2) 255))
+        (b3 (logand (aref bytes 3) 255))
+        (b4 (logand (aref bytes 4) 255))
+        (b5 (logand (aref bytes 5) 255))
+        (b6 (logand (aref bytes 6) 255))
+        (b7 (logand (aref bytes 7) 255)))
+    (let ((result-u63 (+ (ash (logand b0 #x7f) 56) (ash b1 48)
+                         (ash b2 40) (ash b3 32) (ash b4 24) (ash b5 16) (ash 
b6 8) b7)))
+      (if (>= b0 #x80)
+          (- result-u63 (ash 1 63))
+        result-u63))))
+
+(defun gptel-bedrock--bytes-to-uuid (bytes)
+  "Convert a 16-byte unibyte BYTES to a 36 character UUID string."
+  (unless (and (stringp bytes) (= (length bytes) 16))
+    (error "Input must be a 16-byte unibyte string"))
+  (let ((hex (mapconcat (lambda (i) (format "%02x" (aref bytes i))) 
(number-sequence 0 15) "")))
+    (format "%s-%s-%s-%s-%s"
+            (substring hex 0 8)
+            (substring hex 8 12)
+            (substring hex 12 16)
+            (substring hex 16 20)
+            (substring hex 20 32))))
+
+(defun gptel-bedrock--assemble-content-blocks (events)
+  "Build a completed prompt object contained from EVENTS.
+EVENTS should be a list of messageStart, contentBlockStart,
+contentBlockDelta, and contentBlockStop stream messages as
+returned by `gptel-bedrock--parse-stream-message', in the order
+received."
+  (let ((blocks (make-hash-table :test #'eql))
+        role contents)
+    (dolist (event events)
+      (let* ((headers (plist-get event :headers))
+             (payload (plist-get event :payload))
+             (event-type (assoc-default ":event-type" headers)))
+        (pcase event-type
+          ("messageStart" (setq role (plist-get payload :role)))
+          ("contentBlockStart"
+           (puthash (plist-get payload :contentBlockIndex) (list event) 
blocks))
+          ("contentBlockDelta"
+           (push event (gethash (plist-get payload :contentBlockIndex) 
blocks)))
+          ("contentBlockStop"
+           (let* ((block-index (plist-get payload :contentBlockIndex))
+                  (block-events (nreverse (gethash block-index blocks)))
+                  (start (car block-events))
+                  (deltas (cdr block-events)))
+             (when-let ((tool-use (map-nested-elt start '(:payload :start 
:toolUse))))
+               (let ((id (plist-get tool-use :toolUseId))
+                     (name (plist-get tool-use :name))
+                     (input (gptel--json-read-string
+                             (mapconcat
+                              (lambda (delta) (map-nested-elt delta '(:payload 
:delta :toolUse :input)))
+                              deltas))))
+                 (push
+                  (list :toolUse (list :input input :name name :toolUseId id))
+                  contents)))
+             (when-let ((texts (delq nil (mapcar (lambda (d) (map-nested-elt d 
'(:payload :delta :text))) deltas))))
+               (push (list :text (apply #'concat texts)) contents))
+             ;; Currently we discard any reasoning content but this would be 
the spot to handle it
+             ))
+          (_ (error "Unexpected event-type %S" event-type)))))
+    (list :role role :content (vconcat (nreverse contents)))))
+
+(cl-defmethod gptel--parse-buffer ((_backend gptel-bedrock) &optional 
max-entries)
+  "Parse current buffer and return a list of prompt objects for Bedrock.
+
+MAX-ENTRIES is the maximum number of prompts to include."
+  (unless max-entries (setq max-entries most-positive-fixnum))
+  (let ((prompts nil) (prev-pt (point))
+        (include-media (and gptel-track-media (gptel--model-capable-p 
'media))))
+    (cl-flet ((capture-prompt (role beg end)
+                (let* ((content (if include-media
+                                 (gptel-bedrock--parse-multipart
+                                  (gptel--parse-media-links major-mode beg 
end))
+                                 `[(:text ,(gptel--trim-prefixes
+                                           (buffer-substring-no-properties beg 
end)))]))
+                       (prompt (list :role role :content content)))
+                  (push prompt prompts))))
+
+      (if (or gptel-mode gptel-track-response)
+          (while (and (> max-entries 0)
+                      (/= prev-pt (point-min))
+                      (goto-char (previous-single-property-change
+                                  (point) 'gptel nil (point-min))))
+            (capture-prompt (pcase (get-char-property (point) 'gptel)
+                              ('response "assistant")
+                              ('nil "user"))
+                            (point) prev-pt)
+            (setq prev-pt (point))
+            (cl-decf max-entries))
+        (capture-prompt "user" (point-min) (point-max)))
+      prompts)))
+
+(defconst gptel-bedrock--image-formats
+  '(("image/jpg" . "jpeg")
+    ("image/jpeg" . "jpeg")
+    ("image/png" . "png")
+    ("image/gif" . "gif")
+    ("image/webp" . "webp"))
+  "Map of mime type to image formats as used in AWS's ImageBlock.")
+
+(defconst gptel-bedrock--doc-formats
+  '(("application/pdf" . "pdf")
+    ("text/csv" . "csv")
+    ("application/msword" . "doc")
+    ("application/vnd.openxmlformats-officedocument.wordprocessingml.document" 
. "docx")
+    ("application/vnd.ms-excel" . "xls")
+    ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" . 
"xlsx")
+    ("text/html" . "html")
+    ("text/plain" . "txt")
+    ("text/markdown" . "md"))
+  "Map of mime type to document formats as used in AWS's DocumentBlock.")
+
+(defun gptel-bedrock--parse-multipart (parts)
+  "Convert a multipart prompt PARTS to the AWS Bedrock API format.
+
+The input is a list of text and media plists of the form:
+ ((:text \"some text\")
+  (:media \"/path/to/media.png\" :mime \"image/png\")
+  (:text \"More text\")).
+
+The output is a vector of entries in Bedrock API format."
+  (thread-last parts
+    (cl-maplist
+     (lambda (tail)
+       (let* ((part (car tail))
+              (text (plist-get part :text))
+              (mime (plist-get part :mime))
+              (media (plist-get part :media))
+              (textfile (plist-get part :textfile))
+              format)
+         (cond
+          (text (when (or (eq part (car parts)) (null (cdr tail)))
+                  (setq text (gptel--trim-prefixes text)))
+                (unless (string-empty-p text)
+                  `(:text ,text)))
+          (media
+           (cond
+            ((setq format (assoc mime gptel-bedrock--image-formats))
+             `(:image (:format ,(cdr format) :source (:bytes 
,(gptel--base64-encode media)))))
+            ((setq format (assoc mime gptel-bedrock--doc-formats))
+             `(:document (:format ,(cdr format)
+                          :name ,(file-name-nondirectory media)
+                          :source (:bytes ,(gptel--base64-encode media)))))
+            (t (error "Unsupported MIME type %s for AWS Bedrock" mime))))
+          (textfile `(:text ,(with-temp-buffer
+                               (gptel--insert-file-string textfile)
+                               (buffer-string))))))))
+    (delq nil)
+    (vconcat)))
+
+;; gptel--inject-prompt not needed since the default implementation works here
+
+(cl-defmethod gptel--parse-tool-results ((_backend gptel-bedrock) 
tool-use-requests)
+  "Return a backend-appropriate prompt containing tool call results.
+
+TOOL-USE-REQUESTS is a list of request plists that have been
+completed.  Returns a single prompt object to inject into the
+conversation."
+  (list
+   :role "user"
+   :content
+   (vconcat
+    (mapcar
+     (lambda (tool-call)
+       `(:toolResult (:toolUseId ,(plist-get tool-call :id)
+                      :status "success"
+                      :content [(:text ,(plist-get tool-call :result))])))
+     tool-use-requests))))
+
+(defun gptel-bedrock--get-credentials ()
+  "Return the AWS credentials to use for the request.
+
+Returns a list of 2-3 elements, depending on whether a session
+token is needed, with this form: (AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
+AWS_SESSION_TOKEN).
+
+Convenient to use with `cl-multiple-value-bind'"
+  (let ((key-id (getenv "AWS_ACCESS_KEY_ID"))
+        (secret-key (getenv "AWS_SECRET_ACCESS_KEY"))
+        (token (getenv "AWS_SESSION_TOKEN")))
+    (cond
+     ((and key-id secret-key token) (cl-values key-id secret-key token))
+     ((and key-id secret-key) (cl-values key-id secret-key))
+     ;; TODO: Add support for more credential sources
+     (t (user-error "Missing AWS credentials; currently only environment 
variables are supported")))))
+
+(defvar gptel-bedrock--model-ids
+  ;; https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html
+  '((claude-3-5-sonnet-20241022  . "anthropic.claude-3-5-sonnet-20241022-v2:0")
+    (claude-3-5-sonnet-20240620  . "anthropic.claude-3-5-sonnet-20240620-v1:0")
+    (claude-3-5-haiku-20241022   . "anthropic.claude-3-5-haiku-20241022-v1:0")
+    (claude-3-opus-20240229      . "anthropic.claude-3-opus-20240229-v1:0")
+    (claude-3-sonnet-20240229    . "anthropic.claude-3-sonnet-20240229-v1:0")
+    (claude-3-haiku-20240307     . "anthropic.claude-3-haiku-20240307-v1:0")
+    (mistral-7b                  . "mistral.mistral-7b-instruct-v0:2")
+    (mistral-8x7b                . "mistral.mixtral-8x7b-instruct-v0:1")
+    (mistral-large-2402          . "mistral.mistral-large-2402-v1:0")
+    (mistral-large-2407          . "mistral.mistral-large-2407-v1:0")
+    (mistral-small-2402          . "mistral.mistral-small-2402-v1:0")
+    (llama-3-8b                  . "meta.llama3-8b-instruct-v1:0")
+    (llama-3-70b                 . "meta.llama3-70b-instruct-v1:0")
+    (llama-3-1-8b                . "meta.llama3-1-8b-instruct-v1:0")
+    (llama-3-1-70b               . "meta.llama3-1-70b-instruct-v1:0")
+    (llama-3-1-405b              . "meta.llama3-1-405b-instruct-v1:0")
+    (llama-3-2-1b                . "meta.llama3-2-1b-instruct-v1:0")
+    (llama-3-2-3b                . "meta.llama3-2-3b-instruct-v1:0")
+    (llama-3-2-11b               . "meta.llama3-2-11b-instruct-v1:0")
+    (llama-3-2-90b               . "meta.llama3-2-90b-instruct-v1:0")
+    (llama-3-3-70b               . "meta.llama3-3-70b-instruct-v1:0"))
+  "Map of model name to bedrock id.
+
+IDs can be added or replaced by calling
+\(push (model-name . \"model-id\") gptel-bedrock--model-ids).")
+
+(defvar gptel--bedrock-models
+  (let ((known-ids (mapcar #'car gptel-bedrock--model-ids)))
+    (cl-remove-if-not (lambda (model) (memq (car model) known-ids)) 
gptel--anthropic-models))
+  "List of available AWS Bedrock models and associated properties.")
+
+(defun gptel-bedrock--get-model-id (model)
+  "Return the Bedrock model ID for MODEL."
+  (or (alist-get model gptel-bedrock--model-ids nil nil #'eq)
+      (error "Unknown Bedrock model: %s" model)))
+
+(defun gptel-bedrock--curl-args (region)
+  "Generate the curl arguments to get a bedrock request signed for use in 
REGION."
+  ;; https://curl.se/docs/manpage.html#--aws-sigv4
+  (cl-multiple-value-bind (key-id secret token) 
(gptel-bedrock--get-credentials)
+    (nconc
+     (list
+      "--user" (format "%s:%s" key-id secret)
+      "--aws-sigv4" (format "aws:amz:%s:bedrock" region)
+      "--output" "/dev/stdout") ;; Without this curl swallows the output
+     (when token
+       (list (format "-Hx-amz-security-token: %s" token))))))
+
+;;;###autoload
+(cl-defun gptel-make-bedrock
+    (name &key
+          region
+          (models gptel--bedrock-models)
+          (stream nil)
+         curl-args
+          (protocol "https"))
+  "Register an AWS Bedrock backend for gptel with NAME.
+
+Keyword arguments:
+
+REGION - AWS region name (e.g. \"us-east-1\")
+MODELS - The list of models supported by this backend
+STREAM - Whether to use streaming responses or not."
+  (declare (indent 1))
+  (let ((host (format "bedrock-runtime.%s.amazonaws.com" region)))
+    (setf (alist-get name gptel--known-backends nil nil #'equal)
+          (gptel--make-bedrock
+           :name name
+           :host host
+           :header nil           ; x-amz-security-token is set in curl-args if 
needed
+           :models (gptel--process-models models)
+           :protocol protocol
+           :endpoint "" ; Url is dynamically constructed based on other args
+           :stream stream
+           :coding-system (and stream 'binary)
+           :curl-args (lambda () (append curl-args (gptel-bedrock--curl-args 
region)))
+           :url
+           (lambda ()
+             (concat protocol "://" host
+                     "/model/" (gptel-bedrock--get-model-id gptel-model)
+                     "/" (if stream "converse-stream" "converse")))
+           ))))
+
+(provide 'gptel-bedrock)
+;;; gptel-bedrock.el ends here

Reply via email to