1package provider
2
3// Amazon Bedrock Converse-Stream client.
4//
5// Endpoint: POST https://bedrock-runtime.{region}.amazonaws.com/model/{id}/converse-stream
6//
7// Auth: two paths supported.
8//
9// - **Bearer token** via AWS_BEARER_TOKEN_BEDROCK. Modern, simple,
10// `bedrock:CallWithBearerToken` IAM permission required.
11// - **SigV4 signing** via AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY
12// (+ optional AWS_SESSION_TOKEN for temporary credentials, e.g. SSO
13// or IRSA-issued). Hand-rolled here so we don't pull in aws-sdk-go-v2.
14//
15// If both are present, bearer wins (simpler / fewer moving parts).
16//
17// Wire format: AWS event-stream binary framing. Each message is:
18//
19// [4-byte total length BE]
20// [4-byte headers length BE]
21// [4-byte CRC32 of the 8-byte prelude]
22// [headers ...] each = [1B name-length][name][1B value-type][2B value-length][value]
23// [payload ...] payload-length = total - headers - 4 (trailer) - 12 (prelude+prelude-crc)
24// [4-byte CRC32 of message bytes minus trailer]
25//
26// We only consume the stream — never produce it — so we don't validate
27// CRCs (the TLS+TCP transport already protects in-flight bytes). We do
28// validate length-bounds to avoid OOB reads on a hostile / corrupted
29// stream.
30//
31// Event types we care about (from `:event-type` header):
32// - messageStart { role: "assistant" }
33// - contentBlockStart { contentBlockIndex, start: { toolUse: { toolUseId, name } } }
34// - contentBlockDelta { contentBlockIndex, delta: { text?, toolUse?: { input } } }
35// - contentBlockStop { contentBlockIndex }
36// - messageStop { stopReason }
37// - metadata { usage: { inputTokens, outputTokens } }
38
39import (
40 "bytes"
41 "context"
42 "crypto/hmac"
43 "crypto/sha256"
44 "encoding/binary"
45 "encoding/hex"
46 "encoding/json"
47 "fmt"
48 "io"
49 "net/http"
50 "net/url"
51 "os"
52 "sort"
53 "strings"
54 "time"
55)
56
57type bedrockClient struct {
58 // Auth mode is determined at construction time. Exactly one of
59 // bearerToken or sigv4 is populated.
60 bearerToken string
61 sigv4 *bedrockSigV4Creds
62
63 region string
64 baseURL string
65 http *http.Client
66}
67
68// bedrockSigV4Creds holds the IAM credentials used to sign each request.
69type bedrockSigV4Creds struct {
70 accessKeyID string
71 secretAccessKey string
72 sessionToken string // optional; STS / SSO / IRSA temp creds
73}
74
75// NewBedrockClient returns a Bedrock client.
76//
77// Auth resolution (first match wins):
78//
79// 1. apiKey == real bearer-ish string (not "<aws>") -> bearer route.
80// 2. AWS_BEARER_TOKEN_BEDROCK env var -> bearer route.
81// 3. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ optional
82// AWS_SESSION_TOKEN) -> SigV4 route.
83// 4. AWS_PROFILE -> read ~/.aws/credentials, take that profile's keys.
84//
85// region defaults to us-east-1 unless AWS_REGION / AWS_DEFAULT_REGION is
86// set or the baseURL embeds a region.
87func NewBedrockClient(apiKey, baseURL string) Client {
88 region := os.Getenv("AWS_REGION")
89 if region == "" {
90 region = os.Getenv("AWS_DEFAULT_REGION")
91 }
92 if region == "" {
93 region = "us-east-1"
94 }
95 if baseURL == "" {
96 baseURL = "https://bedrock-runtime." + region + ".amazonaws.com"
97 }
98 c := &bedrockClient{
99 region: region,
100 baseURL: strings.TrimRight(baseURL, "/"),
101 http: &http.Client{Timeout: 0},
102 }
103
104 // Bearer route.
105 token := apiKey
106 if token == "" || token == "<aws>" {
107 token = os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
108 }
109 if token != "" && token != "<aws>" {
110 c.bearerToken = token
111 return c
112 }
113
114 // SigV4 route: env vars first.
115 ak := os.Getenv("AWS_ACCESS_KEY_ID")
116 sk := os.Getenv("AWS_SECRET_ACCESS_KEY")
117 st := os.Getenv("AWS_SESSION_TOKEN")
118 if ak != "" && sk != "" {
119 c.sigv4 = &bedrockSigV4Creds{accessKeyID: ak, secretAccessKey: sk, sessionToken: st}
120 return c
121 }
122
123 // SigV4 route: ~/.aws/credentials via AWS_PROFILE.
124 if profile := os.Getenv("AWS_PROFILE"); profile != "" {
125 if creds, err := readAWSCredentialsFile(profile); err == nil {
126 c.sigv4 = creds
127 return c
128 }
129 }
130
131 return &unimplementedClient{
132 name: "amazon-bedrock",
133 hint: "no Bedrock credentials found (set AWS_BEARER_TOKEN_BEDROCK, AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY, or AWS_PROFILE)",
134 }
135}
136
137// readAWSCredentialsFile parses ~/.aws/credentials and returns the
138// access-key/secret-key (and optional session-token) for the named
139// profile. The file is an INI-like format with `[profile]` headers.
140func readAWSCredentialsFile(profile string) (*bedrockSigV4Creds, error) {
141 home, err := os.UserHomeDir()
142 if err != nil {
143 return nil, err
144 }
145 b, err := os.ReadFile(home + "/.aws/credentials")
146 if err != nil {
147 return nil, err
148 }
149 var current string
150 creds := map[string]map[string]string{}
151 for _, line := range strings.Split(string(b), "\n") {
152 line = strings.TrimSpace(line)
153 if line == "" || strings.HasPrefix(line, "#") {
154 continue
155 }
156 if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
157 current = strings.TrimSpace(line[1 : len(line)-1])
158 creds[current] = map[string]string{}
159 continue
160 }
161 if current == "" {
162 continue
163 }
164 eq := strings.IndexByte(line, '=')
165 if eq < 0 {
166 continue
167 }
168 k := strings.TrimSpace(line[:eq])
169 v := strings.TrimSpace(line[eq+1:])
170 creds[current][k] = v
171 }
172 p, ok := creds[profile]
173 if !ok {
174 return nil, fmt.Errorf("aws profile %q not found in ~/.aws/credentials", profile)
175 }
176 ak := p["aws_access_key_id"]
177 sk := p["aws_secret_access_key"]
178 if ak == "" || sk == "" {
179 return nil, fmt.Errorf("aws profile %q missing aws_access_key_id or aws_secret_access_key", profile)
180 }
181 return &bedrockSigV4Creds{
182 accessKeyID: ak,
183 secretAccessKey: sk,
184 sessionToken: p["aws_session_token"],
185 }, nil
186}
187
188func (c *bedrockClient) Name() string { return "amazon-bedrock" }
189
190// ---- request building ----
191
192type bedrockMessage struct {
193 Role string `json:"role"`
194 Content []map[string]interface{} `json:"content"`
195}
196
197type bedrockToolSpec struct {
198 ToolSpec struct {
199 Name string `json:"name"`
200 Description string `json:"description,omitempty"`
201 InputSchema struct {
202 JSON json.RawMessage `json:"json"`
203 } `json:"inputSchema"`
204 } `json:"toolSpec"`
205}
206
207type bedrockRequest struct {
208 Messages []bedrockMessage `json:"messages"`
209 System []map[string]string `json:"system,omitempty"`
210 InferenceConfig struct {
211 MaxTokens int `json:"maxTokens,omitempty"`
212 Temperature *float32 `json:"temperature,omitempty"`
213 } `json:"inferenceConfig,omitempty"`
214 ToolConfig *struct {
215 Tools []bedrockToolSpec `json:"tools"`
216 } `json:"toolConfig,omitempty"`
217}
218
219func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
220 out := &bedrockRequest{}
221 if req.System != "" {
222 out.System = []map[string]string{{"text": req.System}}
223 }
224 out.InferenceConfig.Temperature = req.Temperature
225 out.InferenceConfig.MaxTokens = req.MaxTokens
226 if out.InferenceConfig.MaxTokens == 0 {
227 out.InferenceConfig.MaxTokens = 4096
228 }
229 for _, m := range req.Messages {
230 role := string(m.Role)
231 if role == "tool" {
232 role = "user"
233 }
234 bm := bedrockMessage{Role: role}
235 for _, c := range m.Content {
236 switch v := c.(type) {
237 case TextBlock:
238 bm.Content = append(bm.Content, map[string]interface{}{"text": v.Text})
239 case ToolCallBlock:
240 var input map[string]interface{}
241 _ = json.Unmarshal(v.Arguments, &input)
242 if input == nil {
243 input = map[string]interface{}{}
244 }
245 bm.Content = append(bm.Content, map[string]interface{}{
246 "toolUse": map[string]interface{}{
247 "toolUseId": v.ID, "name": v.Name, "input": input,
248 },
249 })
250 case ToolResultBlock:
251 var resultContent []map[string]interface{}
252 for _, inner := range v.Content {
253 if tb, ok := inner.(TextBlock); ok {
254 resultContent = append(resultContent, map[string]interface{}{"text": tb.Text})
255 }
256 }
257 status := "success"
258 if v.IsError {
259 status = "error"
260 }
261 bm.Content = append(bm.Content, map[string]interface{}{
262 "toolResult": map[string]interface{}{
263 "toolUseId": v.CallID,
264 "content": resultContent,
265 "status": status,
266 },
267 })
268 }
269 }
270 out.Messages = append(out.Messages, bm)
271 }
272 if len(req.Tools) > 0 {
273 tc := struct {
274 Tools []bedrockToolSpec `json:"tools"`
275 }{}
276 for _, t := range req.Tools {
277 var ts bedrockToolSpec
278 ts.ToolSpec.Name = t.Name
279 ts.ToolSpec.Description = t.Description
280 ts.ToolSpec.InputSchema.JSON = t.Schema
281 tc.Tools = append(tc.Tools, ts)
282 }
283 out.ToolConfig = &tc
284 }
285 return out, nil
286}
287
288func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
289 wire, err := c.buildRequest(req)
290 if err != nil {
291 return nil, err
292 }
293 body, err := json.Marshal(wire)
294 if err != nil {
295 return nil, err
296 }
297 url := c.baseURL + "/model/" + req.Model + "/converse-stream"
298 httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
299 if err != nil {
300 return nil, err
301 }
302 httpReq.Header.Set("content-type", "application/json")
303 httpReq.Header.Set("accept", "application/vnd.amazon.eventstream")
304 if c.bearerToken != "" {
305 httpReq.Header.Set("authorization", "Bearer "+c.bearerToken)
306 } else if c.sigv4 != nil {
307 if err := signSigV4(httpReq, body, "bedrock", c.region, c.sigv4, time.Now().UTC()); err != nil {
308 return nil, fmt.Errorf("bedrock: sigv4 sign: %w", err)
309 }
310 } else {
311 return nil, fmt.Errorf("bedrock: no auth configured")
312 }
313 resp, err := c.http.Do(httpReq)
314 if err != nil {
315 return nil, fmt.Errorf("bedrock: %w", err)
316 }
317 if resp.StatusCode != http.StatusOK {
318 b, _ := io.ReadAll(resp.Body)
319 resp.Body.Close()
320 msg := strings.TrimSpace(string(b))
321 // A 403 on the bearer route is almost always a region mismatch:
322 // short-term Bedrock API keys are scoped to the region of the
323 // console session that minted them, but zot defaults to
324 // us-east-1. Surface the resolved region and the fix so the user
325 // is not left guessing why a freshly-copied key is "invalid".
326 if resp.StatusCode == http.StatusForbidden && c.bearerToken != "" {
327 return nil, fmt.Errorf(
328 "bedrock: http 403 (region=%s): %s\nhint: Bedrock API keys are region-scoped. If your key was created in another region, set AWS_REGION (e.g. AWS_REGION=eu-central-1) or pass --base-url https://bedrock-runtime.<region>.amazonaws.com",
329 c.region, msg)
330 }
331 return nil, fmt.Errorf("bedrock: http %d: %s", resp.StatusCode, msg)
332 }
333 out := make(chan Event, 16)
334 go c.runStream(ctx, resp, req, out)
335 return out, nil
336}
337
338func (c *bedrockClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
339 defer close(out)
340 defer resp.Body.Close()
341
342 out <- EventStart{Provider: "amazon-bedrock", Model: req.Model}
343
344 // State accumulated across deltas.
345 contentBlocks := map[int]*bedrockBlockState{}
346 stop := StopEnd
347 finalMsg := Message{Role: RoleAssistant, Time: time.Now()}
348 var usage Usage
349
350 for {
351 if ctx.Err() != nil {
352 stop = StopAborted
353 break
354 }
355 evt, err := readEventStreamMessage(resp.Body)
356 if err != nil {
357 if err == io.EOF {
358 break
359 }
360 out <- EventDone{Stop: StopError, Err: err, Message: finalMsg}
361 return
362 }
363 eventType := evt.headerString(":event-type")
364 messageType := evt.headerString(":message-type")
365 if messageType == "exception" {
366 out <- EventDone{Stop: StopError, Err: fmt.Errorf("bedrock exception (%s): %s", evt.headerString(":exception-type"), string(evt.payload)), Message: finalMsg}
367 return
368 }
369 switch eventType {
370 case "messageStart":
371 // nothing to do; role is always "assistant"
372 case "contentBlockStart":
373 var d struct {
374 ContentBlockIndex int `json:"contentBlockIndex"`
375 Start struct {
376 ToolUse *struct {
377 ToolUseID string `json:"toolUseId"`
378 Name string `json:"name"`
379 } `json:"toolUse"`
380 } `json:"start"`
381 }
382 if err := json.Unmarshal(evt.payload, &d); err != nil {
383 continue
384 }
385 st := &bedrockBlockState{}
386 contentBlocks[d.ContentBlockIndex] = st
387 if d.Start.ToolUse != nil {
388 st.isToolUse = true
389 st.toolID = d.Start.ToolUse.ToolUseID
390 st.toolName = d.Start.ToolUse.Name
391 out <- EventToolStart{ID: st.toolID, Name: st.toolName}
392 }
393 case "contentBlockDelta":
394 var d struct {
395 ContentBlockIndex int `json:"contentBlockIndex"`
396 Delta struct {
397 Text string `json:"text"`
398 ToolUse *struct {
399 Input string `json:"input"`
400 } `json:"toolUse"`
401 } `json:"delta"`
402 }
403 if err := json.Unmarshal(evt.payload, &d); err != nil {
404 continue
405 }
406 st := contentBlocks[d.ContentBlockIndex]
407 if st == nil {
408 continue
409 }
410 if d.Delta.Text != "" {
411 st.text.WriteString(d.Delta.Text)
412 out <- EventTextDelta{Delta: d.Delta.Text}
413 }
414 if d.Delta.ToolUse != nil && d.Delta.ToolUse.Input != "" {
415 st.toolArgs.WriteString(d.Delta.ToolUse.Input)
416 out <- EventToolArgs{ID: st.toolID, Delta: d.Delta.ToolUse.Input}
417 }
418 case "contentBlockStop":
419 var d struct {
420 ContentBlockIndex int `json:"contentBlockIndex"`
421 }
422 if err := json.Unmarshal(evt.payload, &d); err != nil {
423 continue
424 }
425 st := contentBlocks[d.ContentBlockIndex]
426 if st == nil {
427 continue
428 }
429 if st.isToolUse {
430 args := st.toolArgs.String()
431 if args == "" {
432 args = "{}"
433 }
434 finalMsg.Content = append(finalMsg.Content, ToolCallBlock{
435 ID: st.toolID, Name: st.toolName, Arguments: json.RawMessage(args),
436 })
437 out <- EventToolEnd{ID: st.toolID}
438 } else if st.text.Len() > 0 {
439 finalMsg.Content = append(finalMsg.Content, TextBlock{Text: st.text.String()})
440 }
441 case "messageStop":
442 var d struct {
443 StopReason string `json:"stopReason"`
444 }
445 _ = json.Unmarshal(evt.payload, &d)
446 switch d.StopReason {
447 case "tool_use":
448 stop = StopToolUse
449 case "end_turn":
450 stop = StopEnd
451 case "max_tokens":
452 stop = StopLength
453 default:
454 stop = StopEnd
455 }
456 case "metadata":
457 var d struct {
458 Usage struct {
459 InputTokens int `json:"inputTokens"`
460 OutputTokens int `json:"outputTokens"`
461 CacheReadInputTokens int `json:"cacheReadInputTokens"`
462 CacheWriteInputTokens int `json:"cacheWriteInputTokens"`
463 } `json:"usage"`
464 }
465 if err := json.Unmarshal(evt.payload, &d); err == nil {
466 usage.InputTokens = d.Usage.InputTokens
467 usage.OutputTokens = d.Usage.OutputTokens
468 usage.CacheReadTokens = d.Usage.CacheReadInputTokens
469 usage.CacheWriteTokens = d.Usage.CacheWriteInputTokens
470 if m, err := FindModel("amazon-bedrock", req.Model); err == nil {
471 usage.CostUSD = ComputeCost(m, usage)
472 }
473 out <- EventUsage{Usage: usage}
474 }
475 }
476 }
477 out <- EventDone{Stop: stop, Message: finalMsg}
478}
479
480type bedrockBlockState struct {
481 isToolUse bool
482 toolID string
483 toolName string
484 toolArgs strings.Builder
485 text strings.Builder
486}
487
488// ---- event-stream binary framing parser ----
489
490type eventStreamMessage struct {
491 headers map[string]string
492 payload []byte
493}
494
495func (m *eventStreamMessage) headerString(name string) string { return m.headers[name] }
496
497// readEventStreamMessage reads one event-stream message from r. Returns
498// io.EOF when the stream is finished cleanly.
499func readEventStreamMessage(r io.Reader) (*eventStreamMessage, error) {
500 var prelude [8]byte
501 if _, err := io.ReadFull(r, prelude[:]); err != nil {
502 return nil, err
503 }
504 totalLen := binary.BigEndian.Uint32(prelude[0:4])
505 headersLen := binary.BigEndian.Uint32(prelude[4:8])
506 if totalLen < 16 || headersLen > totalLen-16 {
507 return nil, fmt.Errorf("bedrock: malformed event-stream prelude (total=%d headers=%d)", totalLen, headersLen)
508 }
509 // Skip prelude CRC (4 bytes); we don't validate.
510 var preludeCRC [4]byte
511 if _, err := io.ReadFull(r, preludeCRC[:]); err != nil {
512 return nil, err
513 }
514 headersBuf := make([]byte, headersLen)
515 if _, err := io.ReadFull(r, headersBuf); err != nil {
516 return nil, err
517 }
518 payloadLen := int(totalLen) - 16 - int(headersLen)
519 payload := make([]byte, payloadLen)
520 if _, err := io.ReadFull(r, payload); err != nil {
521 return nil, err
522 }
523 var trailer [4]byte
524 if _, err := io.ReadFull(r, trailer[:]); err != nil {
525 return nil, err
526 }
527 // Parse headers.
528 hdrs := map[string]string{}
529 i := 0
530 for i < len(headersBuf) {
531 if i+1 > len(headersBuf) {
532 break
533 }
534 nameLen := int(headersBuf[i])
535 i++
536 if i+nameLen > len(headersBuf) {
537 return nil, fmt.Errorf("bedrock: header name overflow")
538 }
539 name := string(headersBuf[i : i+nameLen])
540 i += nameLen
541 if i+1 > len(headersBuf) {
542 return nil, fmt.Errorf("bedrock: header value-type missing")
543 }
544 valueType := headersBuf[i]
545 i++
546 // Type 7 = string with 2-byte length. Type 6 = byte-array similar.
547 // Other types (bool/int/long/timestamp/uuid) we treat as opaque
548 // strings since bedrock's event-type headers are always type 7.
549 switch valueType {
550 case 7, 6:
551 if i+2 > len(headersBuf) {
552 return nil, fmt.Errorf("bedrock: header value length missing")
553 }
554 vLen := int(binary.BigEndian.Uint16(headersBuf[i : i+2]))
555 i += 2
556 if i+vLen > len(headersBuf) {
557 return nil, fmt.Errorf("bedrock: header value overflow")
558 }
559 hdrs[name] = string(headersBuf[i : i+vLen])
560 i += vLen
561 case 0: // bool true
562 hdrs[name] = "true"
563 case 1: // bool false
564 hdrs[name] = "false"
565 case 2: // byte
566 if i+1 > len(headersBuf) {
567 return nil, fmt.Errorf("bedrock: header byte missing")
568 }
569 i++
570 case 3: // int16
571 if i+2 > len(headersBuf) {
572 return nil, fmt.Errorf("bedrock: header int16 missing")
573 }
574 i += 2
575 case 4: // int32
576 if i+4 > len(headersBuf) {
577 return nil, fmt.Errorf("bedrock: header int32 missing")
578 }
579 i += 4
580 case 5: // int64
581 if i+8 > len(headersBuf) {
582 return nil, fmt.Errorf("bedrock: header int64 missing")
583 }
584 i += 8
585 case 8: // timestamp (int64)
586 if i+8 > len(headersBuf) {
587 return nil, fmt.Errorf("bedrock: header timestamp missing")
588 }
589 i += 8
590 case 9: // uuid (16 bytes)
591 if i+16 > len(headersBuf) {
592 return nil, fmt.Errorf("bedrock: header uuid missing")
593 }
594 i += 16
595 default:
596 return nil, fmt.Errorf("bedrock: unknown header value-type %d", valueType)
597 }
598 }
599 return &eventStreamMessage{headers: hdrs, payload: payload}, nil
600}
601
602// ---- SigV4 signing ----
603//
604// AWS Signature Version 4. Implemented from scratch so we don't pull in
605// aws-sdk-go-v2 (large transitive dep tree just for one signer). The
606// algorithm is documented at:
607// https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html
608//
609// Steps:
610//
611// 1. Build the canonical request: method + path + canonical query +
612// canonical headers + signed headers + payload-hash.
613// 2. Build the string-to-sign: algorithm + amz-date + credential scope +
614// sha256(canonical request).
615// 3. Derive the signing key by chaining HMAC-SHA256:
616// kSecret -> kDate -> kRegion -> kService -> kSigning
617// 4. signature = HMAC-SHA256(kSigning, stringToSign)
618// 5. Authorization header: AWS4-HMAC-SHA256 Credential=..., SignedHeaders=..., Signature=...
619//
620// Payload hash is sha256 of the JSON body. For Bedrock's streaming
621// endpoint this works because the request body is fully buffered before
622// the call — we don't need the streaming-payload variant.
623
624func sigHMAC(key, msg []byte) []byte {
625 m := hmac.New(sha256.New, key)
626 m.Write(msg)
627 return m.Sum(nil)
628}
629
630func sigSHA256Hex(b []byte) string {
631 h := sha256.Sum256(b)
632 return hex.EncodeToString(h[:])
633}
634
635func signSigV4(req *http.Request, payload []byte, service, region string, creds *bedrockSigV4Creds, now time.Time) error {
636 amzDate := now.Format("20060102T150405Z")
637 shortDate := now.Format("20060102")
638 credentialScope := shortDate + "/" + region + "/" + service + "/aws4_request"
639
640 // Required headers.
641 req.Header.Set("host", req.URL.Host)
642 req.Header.Set("x-amz-date", amzDate)
643 if creds.sessionToken != "" {
644 req.Header.Set("x-amz-security-token", creds.sessionToken)
645 }
646 payloadHash := sigSHA256Hex(payload)
647 req.Header.Set("x-amz-content-sha256", payloadHash)
648
649 // Canonical headers: lowercase name, trimmed value, sorted by name,
650 // each on its own line ending with \n.
651 var names []string
652 values := map[string]string{}
653 for k, vs := range req.Header {
654 lower := strings.ToLower(k)
655 // Skip authorization (we're computing it) and any
656 // connection-specific headers.
657 if lower == "authorization" {
658 continue
659 }
660 names = append(names, lower)
661 values[lower] = strings.Join(vs, ",")
662 }
663 // host is special — it's not in req.Header by default; set above.
664 sort.Strings(names)
665 var canonHdrs strings.Builder
666 for _, n := range names {
667 canonHdrs.WriteString(n)
668 canonHdrs.WriteByte(':')
669 canonHdrs.WriteString(strings.TrimSpace(values[n]))
670 canonHdrs.WriteByte('\n')
671 }
672 signedHeaders := strings.Join(names, ";")
673
674 // Canonical query string: keys sorted by name, each key and value
675 // URI-escaped (RFC 3986).
676 q := req.URL.Query()
677 var qKeys []string
678 for k := range q {
679 qKeys = append(qKeys, k)
680 }
681 sort.Strings(qKeys)
682 var canonQuery strings.Builder
683 for i, k := range qKeys {
684 if i > 0 {
685 canonQuery.WriteByte('&')
686 }
687 canonQuery.WriteString(url.QueryEscape(k))
688 canonQuery.WriteByte('=')
689 canonQuery.WriteString(url.QueryEscape(q.Get(k)))
690 }
691
692 // Canonical request.
693 canonReq := req.Method + "\n" +
694 canonicalSigV4Path(req.URL.Path) + "\n" +
695 canonQuery.String() + "\n" +
696 canonHdrs.String() + "\n" +
697 signedHeaders + "\n" +
698 payloadHash
699
700 // String to sign.
701 stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + sigSHA256Hex([]byte(canonReq))
702
703 // Signing key.
704 kDate := sigHMAC([]byte("AWS4"+creds.secretAccessKey), []byte(shortDate))
705 kRegion := sigHMAC(kDate, []byte(region))
706 kService := sigHMAC(kRegion, []byte(service))
707 kSigning := sigHMAC(kService, []byte("aws4_request"))
708
709 // Signature.
710 signature := hex.EncodeToString(sigHMAC(kSigning, []byte(stringToSign)))
711
712 // Authorization header.
713 auth := "AWS4-HMAC-SHA256 " +
714 "Credential=" + creds.accessKeyID + "/" + credentialScope + ", " +
715 "SignedHeaders=" + signedHeaders + ", " +
716 "Signature=" + signature
717 req.Header.Set("Authorization", auth)
718 return nil
719}
720
721// canonicalSigV4Path returns the path with each segment URI-encoded
722// according to SigV4 rules (everything except unreserved chars
723// A-Za-z0-9-_.~ and / between segments). Bedrock model IDs contain
724// dots, colons, and slashes; the colons must be percent-encoded.
725func canonicalSigV4Path(p string) string {
726 if p == "" {
727 return "/"
728 }
729 segments := strings.Split(p, "/")
730 for i, s := range segments {
731 segments[i] = sigV4EncodeSegment(s)
732 }
733 return strings.Join(segments, "/")
734}
735
736func sigV4EncodeSegment(s string) string {
737 var b strings.Builder
738 for i := 0; i < len(s); i++ {
739 c := s[i]
740 // Unreserved per RFC 3986.
741 if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
742 c == '-' || c == '_' || c == '.' || c == '~' {
743 b.WriteByte(c)
744 continue
745 }
746 fmt.Fprintf(&b, "%%%02X", c)
747 }
748 return b.String()
749}