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}