v0.4.10 5ac0009

discover_bedrock.go RawHistoryFolder

1package provider
2
3// Amazon Bedrock live model discovery.
4//
5// Enumerates the foundation models and cross-region inference profiles
6// available to the caller's AWS credentials, using the Bedrock control
7// plane (bedrock.{region}.amazonaws.com), SigV4-signed with the same
8// signer as the runtime client (packages/provider/amazon_bedrock.go):
9//
10//   - ListFoundationModels: the base foundation-model IDs.
11//   - ListInferenceProfiles: the us./eu./global./... prefixed IDs zot
12//     actually invokes for on-demand throughput.
13//
14// IMPORTANT auth note: the runtime bearer token (AWS_BEARER_TOKEN_BEDROCK,
15// scoped to bedrock:CallWithBearerToken) cannot call these control-plane
16// APIs. Discovery therefore requires SigV4 credentials (env keys, an
17// AWS_PROFILE, or CLI-resolved SSO/assume-role creds). When only a
18// bearer token is present, discovery is skipped and the built-in catalog
19// remains the source of truth.
20//
21// Pricing is intentionally NOT fetched. The AWS Price List API
22// (GetProducts, ServiceCode=AmazonBedrock) was evaluated against live
23// data and found to carry only stale, input-only rows for a handful of
24// legacy models (Claude 2.x / 3 Sonnet / 3 Haiku) with no output or
25// cache prices for any current model. See aws-cli issue #9567. The
26// hand-maintained catalog in catalog_builtin.go stays authoritative for
27// prices, context windows, and capability flags; MergeCatalog keeps that
28// metadata when a discovered ID matches a catalog entry, and unknown
29// discovered IDs surface in the picker with placeholder prices.
30
31import (
32	"bytes"
33	"context"
34	"encoding/json"
35	"fmt"
36	"io"
37	"maps"
38	"net/http"
39	"net/url"
40	"strings"
41	"time"
42)
43
44// bytesReader returns a reader for payload, or nil when payload is nil
45// so GET requests carry no body.
46func bytesReader(payload []byte) io.Reader {
47	if payload == nil {
48		return nil
49	}
50	return bytes.NewReader(payload)
51}
52
53// DiscoverBedrock lists the foundation models and inference profiles
54// available to the caller's AWS credentials in the given region.
55//
56// SigV4 credentials are resolved from the standard AWS environment /
57// profile / CLI sources, even when a bearer token is used for inference.
58//
59// region defaults to AWS_REGION / AWS_DEFAULT_REGION / us-east-1 when
60// empty. A nil error with a nil slice means "no SigV4 credentials" or
61// "no models"; callers should treat that as a skip.
62func DiscoverBedrock(ctx context.Context, region string) ([]Model, error) {
63	if region == "" {
64		region = bedrockResolveRegion()
65	}
66	sigv4 := resolveBedrockDiscoveryCreds()
67	if sigv4 == nil {
68		// Bearer-only or unauthenticated: control-plane APIs are
69		// unreachable. Skip quietly so the catalog stands.
70		return nil, nil
71	}
72
73	client := &http.Client{Timeout: 15 * time.Second}
74	ids, err := bedrockListModelIDs(ctx, client, sigv4, region, "https://bedrock."+region+".amazonaws.com")
75	if err != nil {
76		return nil, err
77	}
78	if len(ids) == 0 {
79		return nil, nil
80	}
81
82	out := make([]Model, 0, len(ids))
83	for _, id := range ids {
84		out = append(out, bedrockDiscoveredModel(id, region))
85	}
86	return out, nil
87}
88
89// bedrockDiscoveredModel builds the Model for a discovered ID. The
90// control plane returns IDs only, so limits and capabilities come from
91// the static catalog. MergeCatalog already uses an exact catalog match;
92// this handles IDs without one, most often a geo-prefixed inference
93// profile (apac., us-gov., ...) whose base model is catalogued. Without
94// this the model would carry MaxOutput 0 and the Bedrock client would
95// cap every response at its 4096-token fallback.
96func bedrockDiscoveredModel(id, region string) Model {
97	m := Model{
98		Provider: "amazon-bedrock",
99		ID:       id,
100		Source:   "live",
101		BaseURL:  "https://bedrock-runtime." + region + ".amazonaws.com",
102	}
103	if bedrockInCatalog(id) {
104		// MergeCatalog keeps the exact catalog entry's metadata.
105		return m
106	}
107	prefix, base := bedrockSplitGeoPrefix(id)
108	if c, ok := bedrockCatalogBase(base); ok {
109		m.ContextWindow = c.ContextWindow
110		m.MaxOutput = c.MaxOutput
111		m.Reasoning = c.Reasoning
112		m.ReasoningLevelMap = maps.Clone(c.ReasoningLevelMap)
113		m.AdaptiveThinking = c.AdaptiveThinking
114		m.PriceInput = c.PriceInput
115		m.PriceOutput = c.PriceOutput
116		m.PriceCacheRead = c.PriceCacheRead
117		m.PriceCacheWrite = c.PriceCacheWrite
118		m.PriceTierInputTokens = c.PriceTierInputTokens
119		m.PriceInputAbove = c.PriceInputAbove
120		m.PriceOutputAbove = c.PriceOutputAbove
121		m.PriceCacheReadAbove = c.PriceCacheReadAbove
122		m.PriceCacheWriteAbove = c.PriceCacheWriteAbove
123		if c.DisplayName != "" {
124			m.DisplayName = c.DisplayName
125			if cp, _ := bedrockSplitGeoPrefix(c.ID); cp != "" {
126				suffix := " (" + cp + ")"
127				if strings.HasSuffix(strings.ToUpper(m.DisplayName), strings.ToUpper(suffix)) {
128					m.DisplayName = m.DisplayName[:len(m.DisplayName)-len(suffix)]
129				}
130			}
131			if prefix != "" {
132				m.DisplayName += " (" + strings.ToUpper(prefix) + ")"
133			}
134		}
135		return m
136	}
137	// Unknown models have no verified output or context limits. Keep the
138	// client's 4096-token fallback rather than guessing from the family:
139	// even Claude Opus 4 supports only 32k, not the 64k of newer models.
140	return m
141}
142
143// bedrockSplitGeoPrefix separates a cross-region inference-profile geo
144// prefix from the foundation-model ID. prefix is "" when there is none.
145func bedrockSplitGeoPrefix(id string) (prefix, base string) {
146	for _, p := range bedrockGeoPrefixes {
147		if strings.HasPrefix(id, p+".") {
148			return p, id[len(p)+1:]
149		}
150	}
151	return "", id
152}
153
154func bedrockInCatalog(id string) bool {
155	for _, c := range Catalog {
156		if c.Provider == "amazon-bedrock" && c.ID == id {
157			return true
158		}
159	}
160	return false
161}
162
163// bedrockCatalogBase finds catalog metadata for a foundation-model ID:
164// the bare entry if present, otherwise any geo-prefixed variant of it.
165func bedrockCatalogBase(base string) (Model, bool) {
166	var variant Model
167	found := false
168	for _, c := range Catalog {
169		if c.Provider != "amazon-bedrock" {
170			continue
171		}
172		if c.ID == base {
173			return c, true
174		}
175		if !found {
176			if _, b := bedrockSplitGeoPrefix(c.ID); b == base {
177				variant, found = c, true
178			}
179		}
180	}
181	return variant, found
182}
183
184// bedrockListModelIDs enumerates foundation-model IDs and inference-
185// profile IDs available in the region. Profile IDs are the geo-prefixed
186// forms zot invokes; both are returned so the picker shows every
187// selectable variant. controlPlaneBase is the scheme+host (no trailing
188// slash) of the Bedrock control plane, parameterized for testing.
189func bedrockListModelIDs(ctx context.Context, client *http.Client, creds *bedrockSigV4Creds, region, controlPlaneBase string) ([]string, error) {
190	seen := map[string]bool{}
191	var ids []string
192	add := func(id string) {
193		id = strings.TrimSpace(id)
194		if id == "" || seen[id] {
195			return
196		}
197		seen[id] = true
198		ids = append(ids, id)
199	}
200
201	// ListFoundationModels: GET, no pagination token in the response.
202	fmURL := controlPlaneBase + "/foundation-models"
203	body, err := bedrockSignedControlPlane(ctx, client, creds, region, http.MethodGet, fmURL, nil)
204	if err != nil {
205		return nil, err
206	}
207	var fm struct {
208		ModelSummaries []struct {
209			ModelID          string   `json:"modelId"`
210			OutputModalities []string `json:"outputModalities"`
211		} `json:"modelSummaries"`
212	}
213	if err := json.Unmarshal(body, &fm); err != nil {
214		return nil, fmt.Errorf("bedrock discover: parse foundation-models: %w", err)
215	}
216	for _, s := range fm.ModelSummaries {
217		// Keep text-output models only; skip embeddings/image generators.
218		if !bedrockEmitsText(s.OutputModalities) {
219			continue
220		}
221		add(s.ModelID)
222	}
223
224	// ListInferenceProfiles: GET, paginated via ?nextToken=.
225	next := ""
226	for {
227		query := url.Values{"maxResults": {"1000"}}
228		if next != "" {
229			query.Set("nextToken", next)
230		}
231		ipURL := controlPlaneBase + "/inference-profiles?" + query.Encode()
232		ipBody, err := bedrockSignedControlPlane(ctx, client, creds, region, http.MethodGet, ipURL, nil)
233		if err != nil {
234			// Non-fatal: foundation models already gathered.
235			break
236		}
237		var ip struct {
238			InferenceProfileSummaries []struct {
239				InferenceProfileID string `json:"inferenceProfileId"`
240			} `json:"inferenceProfileSummaries"`
241			NextToken string `json:"nextToken"`
242		}
243		if err := json.Unmarshal(ipBody, &ip); err != nil {
244			break
245		}
246		for _, s := range ip.InferenceProfileSummaries {
247			add(s.InferenceProfileID)
248		}
249		if ip.NextToken == "" {
250			break
251		}
252		next = ip.NextToken
253	}
254
255	return ids, nil
256}
257
258func bedrockEmitsText(modalities []string) bool {
259	if len(modalities) == 0 {
260		return true // Unknown: assume usable rather than hide it.
261	}
262	for _, m := range modalities {
263		if strings.EqualFold(m, "TEXT") {
264			return true
265		}
266	}
267	return false
268}
269
270// bedrockSignedControlPlane issues a SigV4-signed request to a Bedrock
271// control-plane endpoint and returns the response body. Non-2xx is an
272// error carrying the status and trimmed body.
273func bedrockSignedControlPlane(ctx context.Context, client *http.Client, creds *bedrockSigV4Creds, region, method, url string, payload []byte) ([]byte, error) {
274	req, err := http.NewRequestWithContext(ctx, method, url, bytesReader(payload))
275	if err != nil {
276		return nil, err
277	}
278	if payload != nil {
279		req.Header.Set("content-type", "application/json")
280	}
281	if err := signSigV4(req, payload, "bedrock", region, creds, time.Now().UTC()); err != nil {
282		return nil, fmt.Errorf("bedrock discover: sign: %w", err)
283	}
284	resp, err := client.Do(req)
285	if err != nil {
286		return nil, fmt.Errorf("bedrock discover: %w", err)
287	}
288	defer resp.Body.Close()
289	b, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
290	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
291		return nil, fmt.Errorf("bedrock discover: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
292	}
293	return b, nil
294}

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