v0.2.7 ea58887

amazon_bedrock.go RawHistoryFolder

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		return nil, fmt.Errorf("bedrock: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
321	}
322	out := make(chan Event, 16)
323	go c.runStream(ctx, resp, req, out)
324	return out, nil
325}
326
327func (c *bedrockClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
328	defer close(out)
329	defer resp.Body.Close()
330
331	out <- EventStart{Provider: "amazon-bedrock", Model: req.Model}
332
333	// State accumulated across deltas.
334	contentBlocks := map[int]*bedrockBlockState{}
335	stop := StopEnd
336	finalMsg := Message{Role: RoleAssistant, Time: time.Now()}
337	var usage Usage
338
339	for {
340		if ctx.Err() != nil {
341			stop = StopAborted
342			break
343		}
344		evt, err := readEventStreamMessage(resp.Body)
345		if err != nil {
346			if err == io.EOF {
347				break
348			}
349			out <- EventDone{Stop: StopError, Err: err, Message: finalMsg}
350			return
351		}
352		eventType := evt.headerString(":event-type")
353		messageType := evt.headerString(":message-type")
354		if messageType == "exception" {
355			out <- EventDone{Stop: StopError, Err: fmt.Errorf("bedrock exception (%s): %s", evt.headerString(":exception-type"), string(evt.payload)), Message: finalMsg}
356			return
357		}
358		switch eventType {
359		case "messageStart":
360			// nothing to do; role is always "assistant"
361		case "contentBlockStart":
362			var d struct {
363				ContentBlockIndex int `json:"contentBlockIndex"`
364				Start             struct {
365					ToolUse *struct {
366						ToolUseID string `json:"toolUseId"`
367						Name      string `json:"name"`
368					} `json:"toolUse"`
369				} `json:"start"`
370			}
371			if err := json.Unmarshal(evt.payload, &d); err != nil {
372				continue
373			}
374			st := &bedrockBlockState{}
375			contentBlocks[d.ContentBlockIndex] = st
376			if d.Start.ToolUse != nil {
377				st.isToolUse = true
378				st.toolID = d.Start.ToolUse.ToolUseID
379				st.toolName = d.Start.ToolUse.Name
380				out <- EventToolStart{ID: st.toolID, Name: st.toolName}
381			}
382		case "contentBlockDelta":
383			var d struct {
384				ContentBlockIndex int `json:"contentBlockIndex"`
385				Delta             struct {
386					Text    string `json:"text"`
387					ToolUse *struct {
388						Input string `json:"input"`
389					} `json:"toolUse"`
390				} `json:"delta"`
391			}
392			if err := json.Unmarshal(evt.payload, &d); err != nil {
393				continue
394			}
395			st := contentBlocks[d.ContentBlockIndex]
396			if st == nil {
397				continue
398			}
399			if d.Delta.Text != "" {
400				st.text.WriteString(d.Delta.Text)
401				out <- EventTextDelta{Delta: d.Delta.Text}
402			}
403			if d.Delta.ToolUse != nil && d.Delta.ToolUse.Input != "" {
404				st.toolArgs.WriteString(d.Delta.ToolUse.Input)
405				out <- EventToolArgs{ID: st.toolID, Delta: d.Delta.ToolUse.Input}
406			}
407		case "contentBlockStop":
408			var d struct {
409				ContentBlockIndex int `json:"contentBlockIndex"`
410			}
411			if err := json.Unmarshal(evt.payload, &d); err != nil {
412				continue
413			}
414			st := contentBlocks[d.ContentBlockIndex]
415			if st == nil {
416				continue
417			}
418			if st.isToolUse {
419				args := st.toolArgs.String()
420				if args == "" {
421					args = "{}"
422				}
423				finalMsg.Content = append(finalMsg.Content, ToolCallBlock{
424					ID: st.toolID, Name: st.toolName, Arguments: json.RawMessage(args),
425				})
426				out <- EventToolEnd{ID: st.toolID}
427			} else if st.text.Len() > 0 {
428				finalMsg.Content = append(finalMsg.Content, TextBlock{Text: st.text.String()})
429			}
430		case "messageStop":
431			var d struct {
432				StopReason string `json:"stopReason"`
433			}
434			_ = json.Unmarshal(evt.payload, &d)
435			switch d.StopReason {
436			case "tool_use":
437				stop = StopToolUse
438			case "end_turn":
439				stop = StopEnd
440			case "max_tokens":
441				stop = StopLength
442			default:
443				stop = StopEnd
444			}
445		case "metadata":
446			var d struct {
447				Usage struct {
448					InputTokens           int `json:"inputTokens"`
449					OutputTokens          int `json:"outputTokens"`
450					CacheReadInputTokens  int `json:"cacheReadInputTokens"`
451					CacheWriteInputTokens int `json:"cacheWriteInputTokens"`
452				} `json:"usage"`
453			}
454			if err := json.Unmarshal(evt.payload, &d); err == nil {
455				usage.InputTokens = d.Usage.InputTokens
456				usage.OutputTokens = d.Usage.OutputTokens
457				usage.CacheReadTokens = d.Usage.CacheReadInputTokens
458				usage.CacheWriteTokens = d.Usage.CacheWriteInputTokens
459				if m, err := FindModel("amazon-bedrock", req.Model); err == nil {
460					usage.CostUSD = ComputeCost(m, usage)
461				}
462				out <- EventUsage{Usage: usage}
463			}
464		}
465	}
466	out <- EventDone{Stop: stop, Message: finalMsg}
467}
468
469type bedrockBlockState struct {
470	isToolUse bool
471	toolID    string
472	toolName  string
473	toolArgs  strings.Builder
474	text      strings.Builder
475}
476
477// ---- event-stream binary framing parser ----
478
479type eventStreamMessage struct {
480	headers map[string]string
481	payload []byte
482}
483
484func (m *eventStreamMessage) headerString(name string) string { return m.headers[name] }
485
486// readEventStreamMessage reads one event-stream message from r. Returns
487// io.EOF when the stream is finished cleanly.
488func readEventStreamMessage(r io.Reader) (*eventStreamMessage, error) {
489	var prelude [8]byte
490	if _, err := io.ReadFull(r, prelude[:]); err != nil {
491		return nil, err
492	}
493	totalLen := binary.BigEndian.Uint32(prelude[0:4])
494	headersLen := binary.BigEndian.Uint32(prelude[4:8])
495	if totalLen < 16 || headersLen > totalLen-16 {
496		return nil, fmt.Errorf("bedrock: malformed event-stream prelude (total=%d headers=%d)", totalLen, headersLen)
497	}
498	// Skip prelude CRC (4 bytes); we don't validate.
499	var preludeCRC [4]byte
500	if _, err := io.ReadFull(r, preludeCRC[:]); err != nil {
501		return nil, err
502	}
503	headersBuf := make([]byte, headersLen)
504	if _, err := io.ReadFull(r, headersBuf); err != nil {
505		return nil, err
506	}
507	payloadLen := int(totalLen) - 16 - int(headersLen)
508	payload := make([]byte, payloadLen)
509	if _, err := io.ReadFull(r, payload); err != nil {
510		return nil, err
511	}
512	var trailer [4]byte
513	if _, err := io.ReadFull(r, trailer[:]); err != nil {
514		return nil, err
515	}
516	// Parse headers.
517	hdrs := map[string]string{}
518	i := 0
519	for i < len(headersBuf) {
520		if i+1 > len(headersBuf) {
521			break
522		}
523		nameLen := int(headersBuf[i])
524		i++
525		if i+nameLen > len(headersBuf) {
526			return nil, fmt.Errorf("bedrock: header name overflow")
527		}
528		name := string(headersBuf[i : i+nameLen])
529		i += nameLen
530		if i+1 > len(headersBuf) {
531			return nil, fmt.Errorf("bedrock: header value-type missing")
532		}
533		valueType := headersBuf[i]
534		i++
535		// Type 7 = string with 2-byte length. Type 6 = byte-array similar.
536		// Other types (bool/int/long/timestamp/uuid) we treat as opaque
537		// strings since bedrock's event-type headers are always type 7.
538		switch valueType {
539		case 7, 6:
540			if i+2 > len(headersBuf) {
541				return nil, fmt.Errorf("bedrock: header value length missing")
542			}
543			vLen := int(binary.BigEndian.Uint16(headersBuf[i : i+2]))
544			i += 2
545			if i+vLen > len(headersBuf) {
546				return nil, fmt.Errorf("bedrock: header value overflow")
547			}
548			hdrs[name] = string(headersBuf[i : i+vLen])
549			i += vLen
550		case 0: // bool true
551			hdrs[name] = "true"
552		case 1: // bool false
553			hdrs[name] = "false"
554		case 2: // byte
555			if i+1 > len(headersBuf) {
556				return nil, fmt.Errorf("bedrock: header byte missing")
557			}
558			i++
559		case 3: // int16
560			if i+2 > len(headersBuf) {
561				return nil, fmt.Errorf("bedrock: header int16 missing")
562			}
563			i += 2
564		case 4: // int32
565			if i+4 > len(headersBuf) {
566				return nil, fmt.Errorf("bedrock: header int32 missing")
567			}
568			i += 4
569		case 5: // int64
570			if i+8 > len(headersBuf) {
571				return nil, fmt.Errorf("bedrock: header int64 missing")
572			}
573			i += 8
574		case 8: // timestamp (int64)
575			if i+8 > len(headersBuf) {
576				return nil, fmt.Errorf("bedrock: header timestamp missing")
577			}
578			i += 8
579		case 9: // uuid (16 bytes)
580			if i+16 > len(headersBuf) {
581				return nil, fmt.Errorf("bedrock: header uuid missing")
582			}
583			i += 16
584		default:
585			return nil, fmt.Errorf("bedrock: unknown header value-type %d", valueType)
586		}
587	}
588	return &eventStreamMessage{headers: hdrs, payload: payload}, nil
589}
590
591// ---- SigV4 signing ----
592//
593// AWS Signature Version 4. Implemented from scratch so we don't pull in
594// aws-sdk-go-v2 (large transitive dep tree just for one signer). The
595// algorithm is documented at:
596//   https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html
597//
598// Steps:
599//
600//   1. Build the canonical request: method + path + canonical query +
601//      canonical headers + signed headers + payload-hash.
602//   2. Build the string-to-sign: algorithm + amz-date + credential scope +
603//      sha256(canonical request).
604//   3. Derive the signing key by chaining HMAC-SHA256:
605//      kSecret -> kDate -> kRegion -> kService -> kSigning
606//   4. signature = HMAC-SHA256(kSigning, stringToSign)
607//   5. Authorization header: AWS4-HMAC-SHA256 Credential=..., SignedHeaders=..., Signature=...
608//
609// Payload hash is sha256 of the JSON body. For Bedrock's streaming
610// endpoint this works because the request body is fully buffered before
611// the call — we don't need the streaming-payload variant.
612
613func sigHMAC(key, msg []byte) []byte {
614	m := hmac.New(sha256.New, key)
615	m.Write(msg)
616	return m.Sum(nil)
617}
618
619func sigSHA256Hex(b []byte) string {
620	h := sha256.Sum256(b)
621	return hex.EncodeToString(h[:])
622}
623
624func signSigV4(req *http.Request, payload []byte, service, region string, creds *bedrockSigV4Creds, now time.Time) error {
625	amzDate := now.Format("20060102T150405Z")
626	shortDate := now.Format("20060102")
627	credentialScope := shortDate + "/" + region + "/" + service + "/aws4_request"
628
629	// Required headers.
630	req.Header.Set("host", req.URL.Host)
631	req.Header.Set("x-amz-date", amzDate)
632	if creds.sessionToken != "" {
633		req.Header.Set("x-amz-security-token", creds.sessionToken)
634	}
635	payloadHash := sigSHA256Hex(payload)
636	req.Header.Set("x-amz-content-sha256", payloadHash)
637
638	// Canonical headers: lowercase name, trimmed value, sorted by name,
639	// each on its own line ending with \n.
640	var names []string
641	values := map[string]string{}
642	for k, vs := range req.Header {
643		lower := strings.ToLower(k)
644		// Skip authorization (we're computing it) and any
645		// connection-specific headers.
646		if lower == "authorization" {
647			continue
648		}
649		names = append(names, lower)
650		values[lower] = strings.Join(vs, ",")
651	}
652	// host is special — it's not in req.Header by default; set above.
653	sort.Strings(names)
654	var canonHdrs strings.Builder
655	for _, n := range names {
656		canonHdrs.WriteString(n)
657		canonHdrs.WriteByte(':')
658		canonHdrs.WriteString(strings.TrimSpace(values[n]))
659		canonHdrs.WriteByte('\n')
660	}
661	signedHeaders := strings.Join(names, ";")
662
663	// Canonical query string: keys sorted by name, each key and value
664	// URI-escaped (RFC 3986).
665	q := req.URL.Query()
666	var qKeys []string
667	for k := range q {
668		qKeys = append(qKeys, k)
669	}
670	sort.Strings(qKeys)
671	var canonQuery strings.Builder
672	for i, k := range qKeys {
673		if i > 0 {
674			canonQuery.WriteByte('&')
675		}
676		canonQuery.WriteString(url.QueryEscape(k))
677		canonQuery.WriteByte('=')
678		canonQuery.WriteString(url.QueryEscape(q.Get(k)))
679	}
680
681	// Canonical request.
682	canonReq := req.Method + "\n" +
683		canonicalSigV4Path(req.URL.Path) + "\n" +
684		canonQuery.String() + "\n" +
685		canonHdrs.String() + "\n" +
686		signedHeaders + "\n" +
687		payloadHash
688
689	// String to sign.
690	stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + sigSHA256Hex([]byte(canonReq))
691
692	// Signing key.
693	kDate := sigHMAC([]byte("AWS4"+creds.secretAccessKey), []byte(shortDate))
694	kRegion := sigHMAC(kDate, []byte(region))
695	kService := sigHMAC(kRegion, []byte(service))
696	kSigning := sigHMAC(kService, []byte("aws4_request"))
697
698	// Signature.
699	signature := hex.EncodeToString(sigHMAC(kSigning, []byte(stringToSign)))
700
701	// Authorization header.
702	auth := "AWS4-HMAC-SHA256 " +
703		"Credential=" + creds.accessKeyID + "/" + credentialScope + ", " +
704		"SignedHeaders=" + signedHeaders + ", " +
705		"Signature=" + signature
706	req.Header.Set("Authorization", auth)
707	return nil
708}
709
710// canonicalSigV4Path returns the path with each segment URI-encoded
711// according to SigV4 rules (everything except unreserved chars
712// A-Za-z0-9-_.~ and / between segments). Bedrock model IDs contain
713// dots, colons, and slashes; the colons must be percent-encoded.
714func canonicalSigV4Path(p string) string {
715	if p == "" {
716		return "/"
717	}
718	segments := strings.Split(p, "/")
719	for i, s := range segments {
720		segments[i] = sigV4EncodeSegment(s)
721	}
722	return strings.Join(segments, "/")
723}
724
725func sigV4EncodeSegment(s string) string {
726	var b strings.Builder
727	for i := 0; i < len(s); i++ {
728		c := s[i]
729		// Unreserved per RFC 3986.
730		if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
731			c == '-' || c == '_' || c == '.' || c == '~' {
732			b.WriteByte(c)
733			continue
734		}
735		fmt.Fprintf(&b, "%%%02X", c)
736	}
737	return b.String()
738}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close