v0.33.0 653c635

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/base64"
45	"encoding/binary"
46	"encoding/hex"
47	"encoding/json"
48	"fmt"
49	"io"
50	"net/http"
51	"net/url"
52	"os"
53	"sort"
54	"strings"
55	"time"
56)
57
58type bedrockClient struct {
59	// Auth mode is determined at construction time. Exactly one of
60	// bearerToken or sigv4 is populated.
61	bearerToken string
62	sigv4       *bedrockSigV4Creds
63
64	region  string
65	baseURL string
66	http    *http.Client
67}
68
69// bedrockSigV4Creds holds the IAM credentials used to sign each request.
70type bedrockSigV4Creds struct {
71	accessKeyID     string
72	secretAccessKey string
73	sessionToken    string // optional; STS / SSO / IRSA temp creds
74}
75
76// NewBedrockClient returns a Bedrock client.
77//
78// Auth resolution (first match wins):
79//
80//  1. apiKey == real bearer-ish string (not "<aws>") -> bearer route.
81//  2. AWS_BEARER_TOKEN_BEDROCK env var -> bearer route.
82//  3. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ optional
83//     AWS_SESSION_TOKEN) -> SigV4 route.
84//  4. AWS_PROFILE -> read ~/.aws/credentials, take that profile's keys.
85//
86// region defaults to us-east-1 unless AWS_REGION / AWS_DEFAULT_REGION is
87// set or the baseURL embeds a region.
88func NewBedrockClient(apiKey, baseURL string) Client {
89	region := os.Getenv("AWS_REGION")
90	if region == "" {
91		region = os.Getenv("AWS_DEFAULT_REGION")
92	}
93	if region == "" {
94		region = "us-east-1"
95	}
96	if baseURL == "" {
97		baseURL = "https://bedrock-runtime." + region + ".amazonaws.com"
98	}
99	c := &bedrockClient{
100		region:  region,
101		baseURL: strings.TrimRight(baseURL, "/"),
102		http:    &http.Client{Timeout: 0},
103	}
104
105	// Bearer route.
106	token := apiKey
107	if token == "" || token == "<aws>" {
108		token = os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
109	}
110	if token != "" && token != "<aws>" {
111		c.bearerToken = token
112		return c
113	}
114
115	// SigV4 route: env vars first.
116	ak := os.Getenv("AWS_ACCESS_KEY_ID")
117	sk := os.Getenv("AWS_SECRET_ACCESS_KEY")
118	st := os.Getenv("AWS_SESSION_TOKEN")
119	if ak != "" && sk != "" {
120		c.sigv4 = &bedrockSigV4Creds{accessKeyID: ak, secretAccessKey: sk, sessionToken: st}
121		return c
122	}
123
124	// SigV4 route: ~/.aws/credentials via AWS_PROFILE.
125	if profile := os.Getenv("AWS_PROFILE"); profile != "" {
126		if creds, err := readAWSCredentialsFile(profile); err == nil {
127			c.sigv4 = creds
128			return c
129		}
130	}
131
132	return &unimplementedClient{
133		name: "amazon-bedrock",
134		hint: "no Bedrock credentials found (set AWS_BEARER_TOKEN_BEDROCK, AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY, or AWS_PROFILE)",
135	}
136}
137
138// readAWSCredentialsFile parses ~/.aws/credentials and returns the
139// access-key/secret-key (and optional session-token) for the named
140// profile. The file is an INI-like format with `[profile]` headers.
141func readAWSCredentialsFile(profile string) (*bedrockSigV4Creds, error) {
142	home, err := os.UserHomeDir()
143	if err != nil {
144		return nil, err
145	}
146	b, err := os.ReadFile(home + "/.aws/credentials")
147	if err != nil {
148		return nil, err
149	}
150	var current string
151	creds := map[string]map[string]string{}
152	for _, line := range strings.Split(string(b), "\n") {
153		line = strings.TrimSpace(line)
154		if line == "" || strings.HasPrefix(line, "#") {
155			continue
156		}
157		if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
158			current = strings.TrimSpace(line[1 : len(line)-1])
159			creds[current] = map[string]string{}
160			continue
161		}
162		if current == "" {
163			continue
164		}
165		eq := strings.IndexByte(line, '=')
166		if eq < 0 {
167			continue
168		}
169		k := strings.TrimSpace(line[:eq])
170		v := strings.TrimSpace(line[eq+1:])
171		creds[current][k] = v
172	}
173	p, ok := creds[profile]
174	if !ok {
175		return nil, fmt.Errorf("aws profile %q not found in ~/.aws/credentials", profile)
176	}
177	ak := p["aws_access_key_id"]
178	sk := p["aws_secret_access_key"]
179	if ak == "" || sk == "" {
180		return nil, fmt.Errorf("aws profile %q missing aws_access_key_id or aws_secret_access_key", profile)
181	}
182	return &bedrockSigV4Creds{
183		accessKeyID:     ak,
184		secretAccessKey: sk,
185		sessionToken:    p["aws_session_token"],
186	}, nil
187}
188
189func (c *bedrockClient) Name() string { return "amazon-bedrock" }
190
191// ---- request building ----
192
193type bedrockMessage struct {
194	Role    string                   `json:"role"`
195	Content []map[string]interface{} `json:"content"`
196}
197
198// bedrockCachePoint is the Converse API cache checkpoint content block.
199// Appending this to a system or message content array tells Bedrock to
200// create a cache boundary at that position in the prompt prefix.
201var bedrockCachePoint = map[string]interface{}{
202	"cachePoint": map[string]interface{}{"type": "default"},
203}
204
205type bedrockToolSpec struct {
206	ToolSpec struct {
207		Name        string `json:"name"`
208		Description string `json:"description,omitempty"`
209		InputSchema struct {
210			JSON json.RawMessage `json:"json"`
211		} `json:"inputSchema"`
212	} `json:"toolSpec"`
213}
214
215type bedrockRequest struct {
216	Messages                     []bedrockMessage         `json:"messages"`
217	System                       []map[string]interface{} `json:"system,omitempty"`
218	AdditionalModelRequestFields map[string]interface{}   `json:"additionalModelRequestFields,omitempty"`
219	InferenceConfig              struct {
220		MaxTokens   int      `json:"maxTokens,omitempty"`
221		Temperature *float32 `json:"temperature,omitempty"`
222	} `json:"inferenceConfig,omitempty"`
223	ToolConfig *struct {
224		Tools []bedrockToolSpec `json:"tools"`
225	} `json:"toolConfig,omitempty"`
226}
227
228func normalizeBedrockToolResults(msgs []Message) []Message {
229	resultByID := map[string]ToolResultBlock{}
230	for _, m := range msgs {
231		for _, c := range m.Content {
232			if tr, ok := c.(ToolResultBlock); ok {
233				if _, exists := resultByID[tr.CallID]; !exists {
234					resultByID[tr.CallID] = tr
235				}
236			}
237		}
238	}
239
240	out := make([]Message, 0, len(msgs))
241	for _, m := range msgs {
242		copy := m
243		copy.Content = nil
244		var toolCalls []ToolCallBlock
245		for _, c := range m.Content {
246			switch v := c.(type) {
247			case ToolResultBlock:
248				// Bedrock requires toolResult blocks immediately after the
249				// assistant toolUse they answer. Reinsert them from the
250				// assistant pass below instead of preserving their original
251				// location, which may be separated by user text in active
252				// sessions.
253				continue
254			case ToolCallBlock:
255				toolCalls = append(toolCalls, v)
256			}
257			copy.Content = append(copy.Content, c)
258		}
259		if len(copy.Content) > 0 {
260			out = append(out, copy)
261		}
262		if m.Role != RoleAssistant || len(toolCalls) == 0 {
263			continue
264		}
265
266		results := make([]Content, 0, len(toolCalls))
267		for _, tc := range toolCalls {
268			if tr, ok := resultByID[tc.ID]; ok {
269				results = append(results, tr)
270				continue
271			}
272			results = append(results, ToolResultBlock{
273				CallID:  tc.ID,
274				IsError: true,
275				Content: []Content{TextBlock{Text: "tool call did not complete before the next user message"}},
276			})
277		}
278		out = append(out, Message{Role: RoleTool, Content: results, Time: m.Time})
279	}
280	return out
281}
282
283// bedrockModelSupportsCaching reports whether the resolved model ID
284// supports explicit prompt caching via cachePoint markers on Bedrock.
285// We use PriceCacheWrite > 0 as a proxy: every Bedrock-hosted Claude
286// model with a write price in the catalog supports cachePoint markers.
287// Nova models use automatic caching and don't need explicit markers.
288func bedrockModelSupportsCaching(modelID string) bool {
289	// Strip geo prefix (us./eu./apac./au./global.) before catalog lookup.
290	for _, p := range bedrockGeoPrefixes {
291		if strings.HasPrefix(modelID, p+".") {
292			modelID = modelID[len(p)+1:]
293			break
294		}
295	}
296	if m, err := FindModel("amazon-bedrock", modelID); err == nil {
297		return m.PriceCacheWrite > 0
298	}
299	// Unknown model: enable for Anthropic Claude families — cachePoint is
300	// silently ignored by the API if the model doesn't support it.
301	return strings.HasPrefix(modelID, "anthropic.claude-")
302}
303
304// bedrockMessagesHaveToolBlocks reports whether any message in msgs
305// contains a toolUse or toolResult content block. Bedrock's Converse API
306// rejects the request with HTTP 400 if those block types are present but
307// toolConfig is absent.
308func bedrockMessagesHaveToolBlocks(msgs []bedrockMessage) bool {
309	for _, m := range msgs {
310		for _, c := range m.Content {
311			if _, ok := c["toolUse"]; ok {
312				return true
313			}
314			if _, ok := c["toolResult"]; ok {
315				return true
316			}
317		}
318	}
319	return false
320}
321
322func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
323	out := &bedrockRequest{}
324
325	// Resolve the model ID as it will appear on the wire so the caching
326	// check operates on the same ID used for FindModel.
327	resolvedModel := resolveBedrockInferenceProfileID(req.Model, c.region)
328	caching := bedrockModelSupportsCaching(resolvedModel)
329	model := Model{ID: resolvedModel}
330	if catalogModel, err := FindModel("amazon-bedrock", resolvedModel); err == nil {
331		model = catalogModel
332	}
333	reasoning := ClampReasoningForModel(model, req.Reasoning)
334	adaptive := usesAdaptiveThinking(model)
335
336	stableSystem, dynamicSystem, messages := systemWithDeveloperContext(req)
337	if stableSystem != "" {
338		sysBlock := map[string]interface{}{"text": stableSystem}
339		if caching {
340			// Append cachePoint after the system text so the stable system
341			// prompt is cached as the first breakpoint.
342			out.System = []map[string]interface{}{sysBlock, bedrockCachePoint}
343		} else {
344			out.System = []map[string]interface{}{sysBlock}
345		}
346	}
347	if dynamicSystem != "" {
348		out.System = append(out.System, map[string]interface{}{"text": dynamicSystem})
349	}
350	if !adaptive {
351		out.InferenceConfig.Temperature = req.Temperature
352	}
353	if adaptive && reasoning != "" {
354		out.AdditionalModelRequestFields = map[string]interface{}{
355			"thinking":      map[string]interface{}{"type": "adaptive"},
356			"output_config": map[string]interface{}{"effort": AnthropicAdaptiveEffort(reasoning)},
357		}
358	}
359	out.InferenceConfig.MaxTokens = req.MaxTokens
360	if out.InferenceConfig.MaxTokens == 0 {
361		out.InferenceConfig.MaxTokens = 4096
362	}
363	for _, m := range normalizeBedrockToolResults(messages) {
364		role := string(m.Role)
365		if role == "tool" {
366			role = "user"
367		}
368		bm := bedrockMessage{Role: role}
369		for _, c := range m.Content {
370			switch v := c.(type) {
371			case TextBlock:
372				bm.Content = append(bm.Content, map[string]interface{}{"text": v.Text})
373			case ToolCallBlock:
374				var input map[string]interface{}
375				_ = json.Unmarshal(v.Arguments, &input)
376				if input == nil {
377					input = map[string]interface{}{}
378				}
379				bm.Content = append(bm.Content, map[string]interface{}{
380					"toolUse": map[string]interface{}{
381						"toolUseId": v.ID, "name": v.Name, "input": input,
382					},
383				})
384			case ImageBlock:
385				data, mime := anthShrinkImageBytesIfTooBig(v.Data, v.MimeType)
386				// Bedrock Converse uses the bare format name (jpeg/png/gif/webp).
387				imgFmt := strings.TrimPrefix(mime, "image/")
388				bm.Content = append(bm.Content, map[string]interface{}{
389					"image": map[string]interface{}{
390						"format": imgFmt,
391						"source": map[string]interface{}{
392							"bytes": base64.StdEncoding.EncodeToString(data),
393						},
394					},
395				})
396			case ToolResultBlock:
397				var resultContent []map[string]interface{}
398				for _, inner := range v.Content {
399					switch iv := inner.(type) {
400					case TextBlock:
401						resultContent = append(resultContent, map[string]interface{}{"text": iv.Text})
402					case ImageBlock:
403						data, mime := anthShrinkImageBytesIfTooBig(iv.Data, iv.MimeType)
404						imgFmt := strings.TrimPrefix(mime, "image/")
405						resultContent = append(resultContent, map[string]interface{}{
406							"image": map[string]interface{}{
407								"format": imgFmt,
408								"source": map[string]interface{}{
409									"bytes": base64.StdEncoding.EncodeToString(data),
410								},
411							},
412						})
413					}
414				}
415				status := "success"
416				if v.IsError {
417					status = "error"
418				}
419				bm.Content = append(bm.Content, map[string]interface{}{
420					"toolResult": map[string]interface{}{
421						"toolUseId": v.CallID,
422						"content":   resultContent,
423						"status":    status,
424					},
425				})
426			}
427		}
428		if len(bm.Content) == 0 {
429			continue
430		}
431		out.Messages = append(out.Messages, bm)
432	}
433	activeTools := activeToolDefinitions(req.Tools, req.Messages)
434	if len(activeTools) > 0 {
435		tc := struct {
436			Tools []bedrockToolSpec `json:"tools"`
437		}{}
438		for _, t := range activeTools {
439			var ts bedrockToolSpec
440			ts.ToolSpec.Name = t.Name
441			ts.ToolSpec.Description = t.Description
442			ts.ToolSpec.InputSchema.JSON = t.Schema
443			tc.Tools = append(tc.Tools, ts)
444		}
445		out.ToolConfig = &tc
446	} else if bedrockMessagesHaveToolBlocks(out.Messages) {
447		// Bedrock requires toolConfig to be present whenever the message
448		// history contains toolUse or toolResult content blocks, even if
449		// the current request does not intend to call any tools (e.g. the
450		// /btw side-chat sends the frozen main transcript which may include
451		// prior tool turns). Inject a stub tool so the API accepts the
452		// request; it will never be invoked since no tool_use stop reason
453		// can be triggered when the real tool list is empty.
454		var stub bedrockToolSpec
455		stub.ToolSpec.Name = "_placeholder"
456		stub.ToolSpec.Description = "placeholder required by Bedrock when tool history is present"
457		stub.ToolSpec.InputSchema.JSON = json.RawMessage(`{"type":"object","properties":{}}`)
458		out.ToolConfig = &struct {
459			Tools []bedrockToolSpec `json:"tools"`
460		}{Tools: []bedrockToolSpec{stub}}
461	}
462
463	if caching {
464		// Tag the last user message with a cachePoint. This extends the
465		// cached prefix to cover the full conversation history up to the
466		// current turn, so the next turn reads that history cheaply.
467		bedrockTagLastUserCache(out.Messages)
468	}
469
470	return out, nil
471}
472
473// bedrockTagLastUserCache appends a cachePoint block to the last user
474// message in the Bedrock message list. It is the Bedrock equivalent of
475// Anthropic's cache_control:{type:"ephemeral"} on the last user message.
476func bedrockTagLastUserCache(msgs []bedrockMessage) {
477	for i := len(msgs) - 1; i >= 0; i-- {
478		if msgs[i].Role == "user" {
479			msgs[i].Content = append(msgs[i].Content, bedrockCachePoint)
480			return
481		}
482	}
483}
484
485// resolveBedrockInferenceProfileID maps a bare foundation-model ID to
486// its region-matched cross-region inference-profile ID.
487//
488// Several newer Bedrock models (Anthropic Claude 4.x, DeepSeek, etc.)
489// cannot be invoked with on-demand throughput by their plain
490// foundation-model ID; Bedrock returns HTTP 400 demanding "the ID or
491// ARN of an inference profile that contains this model". The profile
492// ID is the same model ID with a geographic prefix (us/eu/apac/...).
493//
494// We only rewrite IDs that (a) lack an existing geo prefix and (b)
495// belong to a model family that requires a profile. IDs that already
496// carry a prefix (e.g. "eu.anthropic...", "global.anthropic...") or
497// fully-qualified ARNs are returned unchanged, so explicit user
498// choices and custom application inference profiles still work.
499func resolveBedrockInferenceProfileID(modelID, region string) string {
500	if modelID == "" {
501		return modelID
502	}
503	// ARNs are already inference-profile references; leave untouched.
504	if strings.HasPrefix(modelID, "arn:") {
505		return modelID
506	}
507	// Already geo-prefixed (us. / eu. / apac. / ap. / us-gov. / global.)?
508	if bedrockHasGeoPrefix(modelID) {
509		return modelID
510	}
511	// Opus 5 is exposed for on-demand throughput through the global
512	// inference profile rather than a region-specific profile.
513	if modelID == "anthropic.claude-opus-5" {
514		return "global." + modelID
515	}
516	if !bedrockRequiresInferenceProfile(modelID) {
517		return modelID
518	}
519	prefix := bedrockGeoPrefixForRegion(region)
520	if prefix == "" {
521		return modelID
522	}
523	return prefix + "." + modelID
524}
525
526// bedrockGeoPrefixes are the cross-region inference-profile geo
527// prefixes Bedrock uses. A model ID that starts with one of these
528// (followed by a dot) is already a profile reference.
529var bedrockGeoPrefixes = []string{"us-gov", "us", "eu", "apac", "ap", "global", "au"}
530
531func bedrockHasGeoPrefix(modelID string) bool {
532	for _, p := range bedrockGeoPrefixes {
533		if strings.HasPrefix(modelID, p+".") {
534			return true
535		}
536	}
537	return false
538}
539
540// bedrockRequiresInferenceProfile reports whether a bare
541// foundation-model ID is one of the families AWS only exposes through
542// a cross-region inference profile for on-demand throughput.
543func bedrockRequiresInferenceProfile(modelID string) bool {
544	switch {
545	case strings.HasPrefix(modelID, "anthropic.claude-"):
546		return true
547	case strings.HasPrefix(modelID, "deepseek."):
548		return true
549	default:
550		return false
551	}
552}
553
554// bedrockGeoPrefixForRegion maps an AWS region to the geo prefix used
555// by its cross-region inference profiles. Returns "" when the region
556// has no known mapping, in which case the model ID is left unchanged.
557func bedrockGeoPrefixForRegion(region string) string {
558	switch {
559	case region == "":
560		return "us"
561	case strings.HasPrefix(region, "us-gov-"):
562		return "us-gov"
563	case strings.HasPrefix(region, "us-"):
564		return "us"
565	case strings.HasPrefix(region, "eu-"):
566		return "eu"
567	case strings.HasPrefix(region, "ap-"):
568		return "apac"
569	case strings.HasPrefix(region, "ca-"):
570		return "us"
571	case strings.HasPrefix(region, "sa-"):
572		return "us"
573	default:
574		return "us"
575	}
576}
577
578func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
579	if err := ValidateFastMode(c.Name(), req.FastMode); err != nil {
580		return nil, err
581	}
582	wire, err := c.buildRequest(req)
583	if err != nil {
584		return nil, err
585	}
586	body, err := json.Marshal(wire)
587	if err != nil {
588		return nil, err
589	}
590	modelID := resolveBedrockInferenceProfileID(req.Model, c.region)
591	url := c.baseURL + "/model/" + modelID + "/converse-stream"
592	if c.bearerToken == "" && c.sigv4 == nil {
593		return nil, fmt.Errorf("bedrock: no auth configured")
594	}
595	newRequest := func() (*http.Request, error) {
596		httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
597		if err != nil {
598			return nil, err
599		}
600		httpReq.Header.Set("content-type", "application/json")
601		httpReq.Header.Set("accept", "application/vnd.amazon.eventstream")
602		if c.bearerToken != "" {
603			httpReq.Header.Set("authorization", "Bearer "+c.bearerToken)
604		} else if err := signSigV4(httpReq, body, "bedrock", c.region, c.sigv4, time.Now().UTC()); err != nil {
605			return nil, fmt.Errorf("bedrock: sigv4 sign: %w", err)
606		}
607		return httpReq, nil
608	}
609	resp, err := doStreamWithRetry(ctx, c.http, newRequest, req.Lifecycle)
610	if err != nil {
611		return nil, fmt.Errorf("bedrock: %w", err)
612	}
613	if resp.StatusCode != http.StatusOK {
614		b, _ := io.ReadAll(resp.Body)
615		resp.Body.Close()
616		msg := strings.TrimSpace(string(b))
617		// A 403 on the bearer route is almost always a region mismatch:
618		// short-term Bedrock API keys are scoped to the region of the
619		// console session that minted them, but zut defaults to
620		// us-east-1. Surface the resolved region and the fix so the user
621		// is not left guessing why a freshly-copied key is "invalid".
622		if resp.StatusCode == http.StatusForbidden && c.bearerToken != "" {
623			return nil, fmt.Errorf(
624				"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",
625				c.region, msg)
626		}
627		return nil, fmt.Errorf("bedrock: http %d: %s", resp.StatusCode, msg)
628	}
629	out := make(chan Event, 16)
630	go c.runStream(ctx, resp, req, out)
631	return out, nil
632}
633
634func (c *bedrockClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
635	defer close(out)
636	defer resp.Body.Close()
637
638	out <- EventStart{Provider: "amazon-bedrock", Model: req.Model}
639
640	// State accumulated across deltas.
641	contentBlocks := map[int]*bedrockBlockState{}
642	stop := StopEnd
643	finalMsg := Message{Role: RoleAssistant, Time: time.Now()}
644	var usage Usage
645
646	for {
647		if ctx.Err() != nil {
648			stop = StopAborted
649			break
650		}
651		evt, err := readEventStreamMessage(resp.Body)
652		if err != nil {
653			if err == io.EOF {
654				break
655			}
656			out <- EventDone{Stop: StopError, Err: err, Message: finalMsg}
657			return
658		}
659		eventType := evt.headerString(":event-type")
660		if eventType == "" {
661			eventType = bedrockEventTypeFromPayload(evt.payload)
662		}
663		messageType := evt.headerString(":message-type")
664		if messageType == "exception" {
665			out <- EventDone{Stop: StopError, Err: fmt.Errorf("bedrock exception (%s): %s", evt.headerString(":exception-type"), string(evt.payload)), Message: finalMsg}
666			return
667		}
668		switch eventType {
669		case "messageStart":
670			// nothing to do; role is always "assistant"
671		case "contentBlockStart":
672			var d struct {
673				ContentBlockIndex int `json:"contentBlockIndex"`
674				Start             struct {
675					ToolUse *struct {
676						ToolUseID string `json:"toolUseId"`
677						Name      string `json:"name"`
678					} `json:"toolUse"`
679				} `json:"start"`
680			}
681			if err := unmarshalBedrockEventPayload(evt.payload, "contentBlockStart", &d); err != nil {
682				continue
683			}
684			st := &bedrockBlockState{}
685			contentBlocks[d.ContentBlockIndex] = st
686			if d.Start.ToolUse != nil {
687				st.isToolUse = true
688				st.toolID = d.Start.ToolUse.ToolUseID
689				st.toolName = d.Start.ToolUse.Name
690				out <- EventToolStart{ID: st.toolID, Name: st.toolName}
691			}
692		case "contentBlockDelta":
693			var d struct {
694				ContentBlockIndex int `json:"contentBlockIndex"`
695				Delta             struct {
696					Text    string `json:"text"`
697					ToolUse *struct {
698						Input string `json:"input"`
699					} `json:"toolUse"`
700				} `json:"delta"`
701			}
702			if err := unmarshalBedrockEventPayload(evt.payload, "contentBlockDelta", &d); err != nil {
703				continue
704			}
705			st := contentBlocks[d.ContentBlockIndex]
706			if st == nil {
707				st = &bedrockBlockState{}
708				contentBlocks[d.ContentBlockIndex] = st
709			}
710			if d.Delta.Text != "" {
711				st.text.WriteString(d.Delta.Text)
712				out <- EventTextDelta{Delta: d.Delta.Text}
713			}
714			if d.Delta.ToolUse != nil && d.Delta.ToolUse.Input != "" {
715				st.toolArgs.WriteString(d.Delta.ToolUse.Input)
716				out <- EventToolArgs{ID: st.toolID, Delta: d.Delta.ToolUse.Input}
717			}
718		case "contentBlockStop":
719			var d struct {
720				ContentBlockIndex int `json:"contentBlockIndex"`
721			}
722			if err := unmarshalBedrockEventPayload(evt.payload, "contentBlockStop", &d); err != nil {
723				continue
724			}
725			st := contentBlocks[d.ContentBlockIndex]
726			if st == nil {
727				continue
728			}
729			if st.isToolUse {
730				args := st.toolArgs.String()
731				if args == "" {
732					args = "{}"
733				}
734				finalMsg.Content = append(finalMsg.Content, ToolCallBlock{
735					ID: st.toolID, Name: st.toolName, Arguments: json.RawMessage(args),
736				})
737				out <- EventToolEnd{ID: st.toolID}
738			} else if st.text.Len() > 0 {
739				finalMsg.Content = append(finalMsg.Content, TextBlock{Text: st.text.String()})
740			}
741		case "messageStop":
742			var d struct {
743				StopReason string `json:"stopReason"`
744			}
745			_ = unmarshalBedrockEventPayload(evt.payload, "messageStop", &d)
746			switch d.StopReason {
747			case "tool_use":
748				stop = StopToolUse
749			case "end_turn":
750				stop = StopEnd
751			case "max_tokens":
752				stop = StopLength
753			default:
754				stop = StopEnd
755			}
756		case "metadata":
757			var d struct {
758				Usage struct {
759					InputTokens           int `json:"inputTokens"`
760					OutputTokens          int `json:"outputTokens"`
761					CacheReadInputTokens  int `json:"cacheReadInputTokens"`
762					CacheWriteInputTokens int `json:"cacheWriteInputTokens"`
763				} `json:"usage"`
764			}
765			if err := unmarshalBedrockEventPayload(evt.payload, "metadata", &d); err == nil {
766				usage.InputTokens = d.Usage.InputTokens
767				usage.OutputTokens = d.Usage.OutputTokens
768				usage.CacheReadTokens = d.Usage.CacheReadInputTokens
769				usage.CacheWriteTokens = d.Usage.CacheWriteInputTokens
770				if m, err := FindModel("amazon-bedrock", req.Model); err == nil {
771					usage.CostUSD = ComputeCost(m, usage)
772				}
773				out <- EventUsage{Usage: usage}
774			}
775		}
776	}
777	out <- EventDone{Stop: stop, Message: finalMsg}
778}
779
780type bedrockBlockState struct {
781	isToolUse bool
782	toolID    string
783	toolName  string
784	toolArgs  strings.Builder
785	text      strings.Builder
786}
787
788func bedrockEventTypeFromPayload(payload []byte) string {
789	var outer map[string]json.RawMessage
790	if err := json.Unmarshal(payload, &outer); err != nil {
791		return ""
792	}
793	for _, name := range []string{"messageStart", "contentBlockStart", "contentBlockDelta", "contentBlockStop", "messageStop", "metadata"} {
794		if _, ok := outer[name]; ok {
795			return name
796		}
797	}
798	return ""
799}
800
801func unmarshalBedrockEventPayload(payload []byte, eventType string, dst any) error {
802	var outer map[string]json.RawMessage
803	if err := json.Unmarshal(payload, &outer); err == nil {
804		if wrapped, ok := outer[eventType]; ok {
805			return json.Unmarshal(wrapped, dst)
806		}
807	}
808	if err := json.Unmarshal(payload, dst); err != nil {
809		return fmt.Errorf("bedrock: parse %s payload: %w", eventType, err)
810	}
811	return nil
812}
813
814// ---- event-stream binary framing parser ----
815
816type eventStreamMessage struct {
817	headers map[string]string
818	payload []byte
819}
820
821func (m *eventStreamMessage) headerString(name string) string { return m.headers[name] }
822
823// readEventStreamMessage reads one event-stream message from r. Returns
824// io.EOF when the stream is finished cleanly.
825func readEventStreamMessage(r io.Reader) (*eventStreamMessage, error) {
826	var prelude [8]byte
827	if _, err := io.ReadFull(r, prelude[:]); err != nil {
828		return nil, err
829	}
830	totalLen := binary.BigEndian.Uint32(prelude[0:4])
831	headersLen := binary.BigEndian.Uint32(prelude[4:8])
832	if totalLen < 16 || headersLen > totalLen-16 {
833		return nil, fmt.Errorf("bedrock: malformed event-stream prelude (total=%d headers=%d)", totalLen, headersLen)
834	}
835	// Skip prelude CRC (4 bytes); we don't validate.
836	var preludeCRC [4]byte
837	if _, err := io.ReadFull(r, preludeCRC[:]); err != nil {
838		return nil, err
839	}
840	headersBuf := make([]byte, headersLen)
841	if _, err := io.ReadFull(r, headersBuf); err != nil {
842		return nil, err
843	}
844	payloadLen := int(totalLen) - 16 - int(headersLen)
845	payload := make([]byte, payloadLen)
846	if _, err := io.ReadFull(r, payload); err != nil {
847		return nil, err
848	}
849	var trailer [4]byte
850	if _, err := io.ReadFull(r, trailer[:]); err != nil {
851		return nil, err
852	}
853	// Parse headers.
854	hdrs := map[string]string{}
855	i := 0
856	for i < len(headersBuf) {
857		if i+1 > len(headersBuf) {
858			break
859		}
860		nameLen := int(headersBuf[i])
861		i++
862		if i+nameLen > len(headersBuf) {
863			return nil, fmt.Errorf("bedrock: header name overflow")
864		}
865		name := string(headersBuf[i : i+nameLen])
866		i += nameLen
867		if i+1 > len(headersBuf) {
868			return nil, fmt.Errorf("bedrock: header value-type missing")
869		}
870		valueType := headersBuf[i]
871		i++
872		// Type 7 = string with 2-byte length. Type 6 = byte-array similar.
873		// Other types (bool/int/long/timestamp/uuid) we treat as opaque
874		// strings since bedrock's event-type headers are always type 7.
875		switch valueType {
876		case 7, 6:
877			if i+2 > len(headersBuf) {
878				return nil, fmt.Errorf("bedrock: header value length missing")
879			}
880			vLen := int(binary.BigEndian.Uint16(headersBuf[i : i+2]))
881			i += 2
882			if i+vLen > len(headersBuf) {
883				return nil, fmt.Errorf("bedrock: header value overflow")
884			}
885			hdrs[name] = string(headersBuf[i : i+vLen])
886			i += vLen
887		case 0: // bool true
888			hdrs[name] = "true"
889		case 1: // bool false
890			hdrs[name] = "false"
891		case 2: // byte
892			if i+1 > len(headersBuf) {
893				return nil, fmt.Errorf("bedrock: header byte missing")
894			}
895			i++
896		case 3: // int16
897			if i+2 > len(headersBuf) {
898				return nil, fmt.Errorf("bedrock: header int16 missing")
899			}
900			i += 2
901		case 4: // int32
902			if i+4 > len(headersBuf) {
903				return nil, fmt.Errorf("bedrock: header int32 missing")
904			}
905			i += 4
906		case 5: // int64
907			if i+8 > len(headersBuf) {
908				return nil, fmt.Errorf("bedrock: header int64 missing")
909			}
910			i += 8
911		case 8: // timestamp (int64)
912			if i+8 > len(headersBuf) {
913				return nil, fmt.Errorf("bedrock: header timestamp missing")
914			}
915			i += 8
916		case 9: // uuid (16 bytes)
917			if i+16 > len(headersBuf) {
918				return nil, fmt.Errorf("bedrock: header uuid missing")
919			}
920			i += 16
921		default:
922			return nil, fmt.Errorf("bedrock: unknown header value-type %d", valueType)
923		}
924	}
925	return &eventStreamMessage{headers: hdrs, payload: payload}, nil
926}
927
928// ---- SigV4 signing ----
929//
930// AWS Signature Version 4. Implemented from scratch so we don't pull in
931// aws-sdk-go-v2 (large transitive dep tree just for one signer). The
932// algorithm is documented at:
933//   https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html
934//
935// Steps:
936//
937//   1. Build the canonical request: method + path + canonical query +
938//      canonical headers + signed headers + payload-hash.
939//   2. Build the string-to-sign: algorithm + amz-date + credential scope +
940//      sha256(canonical request).
941//   3. Derive the signing key by chaining HMAC-SHA256:
942//      kSecret -> kDate -> kRegion -> kService -> kSigning
943//   4. signature = HMAC-SHA256(kSigning, stringToSign)
944//   5. Authorization header: AWS4-HMAC-SHA256 Credential=..., SignedHeaders=..., Signature=...
945//
946// Payload hash is sha256 of the JSON body. For Bedrock's streaming
947// endpoint this works because the request body is fully buffered before
948// the call — we don't need the streaming-payload variant.
949
950func sigHMAC(key, msg []byte) []byte {
951	m := hmac.New(sha256.New, key)
952	m.Write(msg)
953	return m.Sum(nil)
954}
955
956func sigSHA256Hex(b []byte) string {
957	h := sha256.Sum256(b)
958	return hex.EncodeToString(h[:])
959}
960
961func signSigV4(req *http.Request, payload []byte, service, region string, creds *bedrockSigV4Creds, now time.Time) error {
962	amzDate := now.Format("20060102T150405Z")
963	shortDate := now.Format("20060102")
964	credentialScope := shortDate + "/" + region + "/" + service + "/aws4_request"
965
966	// Required headers.
967	req.Header.Set("host", req.URL.Host)
968	req.Header.Set("x-amz-date", amzDate)
969	if creds.sessionToken != "" {
970		req.Header.Set("x-amz-security-token", creds.sessionToken)
971	}
972	payloadHash := sigSHA256Hex(payload)
973	req.Header.Set("x-amz-content-sha256", payloadHash)
974
975	// Canonical headers: lowercase name, trimmed value, sorted by name,
976	// each on its own line ending with \n.
977	var names []string
978	values := map[string]string{}
979	for k, vs := range req.Header {
980		lower := strings.ToLower(k)
981		// Skip authorization (we're computing it) and any
982		// connection-specific headers.
983		if lower == "authorization" {
984			continue
985		}
986		names = append(names, lower)
987		values[lower] = strings.Join(vs, ",")
988	}
989	// host is special — it's not in req.Header by default; set above.
990	sort.Strings(names)
991	var canonHdrs strings.Builder
992	for _, n := range names {
993		canonHdrs.WriteString(n)
994		canonHdrs.WriteByte(':')
995		canonHdrs.WriteString(strings.TrimSpace(values[n]))
996		canonHdrs.WriteByte('\n')
997	}
998	signedHeaders := strings.Join(names, ";")
999
1000	// Canonical query string: keys sorted by name, each key and value
1001	// URI-escaped (RFC 3986).
1002	q := req.URL.Query()
1003	var qKeys []string
1004	for k := range q {
1005		qKeys = append(qKeys, k)
1006	}
1007	sort.Strings(qKeys)
1008	var canonQuery strings.Builder
1009	for i, k := range qKeys {
1010		if i > 0 {
1011			canonQuery.WriteByte('&')
1012		}
1013		canonQuery.WriteString(url.QueryEscape(k))
1014		canonQuery.WriteByte('=')
1015		canonQuery.WriteString(url.QueryEscape(q.Get(k)))
1016	}
1017
1018	// Canonical request.
1019	canonReq := req.Method + "\n" +
1020		canonicalSigV4Path(req.URL.Path) + "\n" +
1021		canonQuery.String() + "\n" +
1022		canonHdrs.String() + "\n" +
1023		signedHeaders + "\n" +
1024		payloadHash
1025
1026	// String to sign.
1027	stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + sigSHA256Hex([]byte(canonReq))
1028
1029	// Signing key.
1030	kDate := sigHMAC([]byte("AWS4"+creds.secretAccessKey), []byte(shortDate))
1031	kRegion := sigHMAC(kDate, []byte(region))
1032	kService := sigHMAC(kRegion, []byte(service))
1033	kSigning := sigHMAC(kService, []byte("aws4_request"))
1034
1035	// Signature.
1036	signature := hex.EncodeToString(sigHMAC(kSigning, []byte(stringToSign)))
1037
1038	// Authorization header.
1039	auth := "AWS4-HMAC-SHA256 " +
1040		"Credential=" + creds.accessKeyID + "/" + credentialScope + ", " +
1041		"SignedHeaders=" + signedHeaders + ", " +
1042		"Signature=" + signature
1043	req.Header.Set("Authorization", auth)
1044	return nil
1045}
1046
1047// canonicalSigV4Path returns the path with each segment URI-encoded
1048// according to SigV4 rules (everything except unreserved chars
1049// A-Za-z0-9-_.~ and / between segments). Bedrock model IDs contain
1050// dots, colons, and slashes; the colons must be percent-encoded.
1051func canonicalSigV4Path(p string) string {
1052	if p == "" {
1053		return "/"
1054	}
1055	segments := strings.Split(p, "/")
1056	for i, s := range segments {
1057		segments[i] = sigV4EncodeSegment(s)
1058	}
1059	return strings.Join(segments, "/")
1060}
1061
1062func sigV4EncodeSegment(s string) string {
1063	var b strings.Builder
1064	for i := 0; i < len(s); i++ {
1065		c := s[i]
1066		// Unreserved per RFC 3986.
1067		if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
1068			c == '-' || c == '_' || c == '.' || c == '~' {
1069			b.WriteByte(c)
1070			continue
1071		}
1072		fmt.Fprintf(&b, "%%%02X", c)
1073	}
1074	return b.String()
1075}

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