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 "net/http"
38 "net/url"
39 "strings"
40 "time"
41)
42
43// bytesReader returns a reader for payload, or nil when payload is nil
44// so GET requests carry no body.
45func bytesReader(payload []byte) io.Reader {
46 if payload == nil {
47 return nil
48 }
49 return bytes.NewReader(payload)
50}
51
52// DiscoverBedrock lists the foundation models and inference profiles
53// available to the caller's AWS credentials in the given region.
54//
55// SigV4 credentials are resolved from the standard AWS environment /
56// profile / CLI sources, even when a bearer token is used for inference.
57//
58// region defaults to AWS_REGION / AWS_DEFAULT_REGION / us-east-1 when
59// empty. A nil error with a nil slice means "no SigV4 credentials" or
60// "no models"; callers should treat that as a skip.
61func DiscoverBedrock(ctx context.Context, region string) ([]Model, error) {
62 if region == "" {
63 region = bedrockResolveRegion()
64 }
65 sigv4 := resolveBedrockDiscoveryCreds()
66 if sigv4 == nil {
67 // Bearer-only or unauthenticated: control-plane APIs are
68 // unreachable. Skip quietly so the catalog stands.
69 return nil, nil
70 }
71
72 client := &http.Client{Timeout: 15 * time.Second}
73 ids, err := bedrockListModelIDs(ctx, client, sigv4, region, "https://bedrock."+region+".amazonaws.com")
74 if err != nil {
75 return nil, err
76 }
77 if len(ids) == 0 {
78 return nil, nil
79 }
80
81 out := make([]Model, 0, len(ids))
82 for _, id := range ids {
83 out = append(out, Model{
84 Provider: "amazon-bedrock",
85 ID: id,
86 Source: "live",
87 BaseURL: "https://bedrock-runtime." + region + ".amazonaws.com",
88 })
89 }
90 return out, nil
91}
92
93// bedrockListModelIDs enumerates foundation-model IDs and inference-
94// profile IDs available in the region. Profile IDs are the geo-prefixed
95// forms zot invokes; both are returned so the picker shows every
96// selectable variant. controlPlaneBase is the scheme+host (no trailing
97// slash) of the Bedrock control plane, parameterized for testing.
98func bedrockListModelIDs(ctx context.Context, client *http.Client, creds *bedrockSigV4Creds, region, controlPlaneBase string) ([]string, error) {
99 seen := map[string]bool{}
100 var ids []string
101 add := func(id string) {
102 id = strings.TrimSpace(id)
103 if id == "" || seen[id] {
104 return
105 }
106 seen[id] = true
107 ids = append(ids, id)
108 }
109
110 // ListFoundationModels: GET, no pagination token in the response.
111 fmURL := controlPlaneBase + "/foundation-models"
112 body, err := bedrockSignedControlPlane(ctx, client, creds, region, http.MethodGet, fmURL, nil)
113 if err != nil {
114 return nil, err
115 }
116 var fm struct {
117 ModelSummaries []struct {
118 ModelID string `json:"modelId"`
119 OutputModalities []string `json:"outputModalities"`
120 } `json:"modelSummaries"`
121 }
122 if err := json.Unmarshal(body, &fm); err != nil {
123 return nil, fmt.Errorf("bedrock discover: parse foundation-models: %w", err)
124 }
125 for _, s := range fm.ModelSummaries {
126 // Keep text-output models only; skip embeddings/image generators.
127 if !bedrockEmitsText(s.OutputModalities) {
128 continue
129 }
130 add(s.ModelID)
131 }
132
133 // ListInferenceProfiles: GET, paginated via ?nextToken=.
134 next := ""
135 for {
136 query := url.Values{"maxResults": {"1000"}}
137 if next != "" {
138 query.Set("nextToken", next)
139 }
140 ipURL := controlPlaneBase + "/inference-profiles?" + query.Encode()
141 ipBody, err := bedrockSignedControlPlane(ctx, client, creds, region, http.MethodGet, ipURL, nil)
142 if err != nil {
143 // Non-fatal: foundation models already gathered.
144 break
145 }
146 var ip struct {
147 InferenceProfileSummaries []struct {
148 InferenceProfileID string `json:"inferenceProfileId"`
149 } `json:"inferenceProfileSummaries"`
150 NextToken string `json:"nextToken"`
151 }
152 if err := json.Unmarshal(ipBody, &ip); err != nil {
153 break
154 }
155 for _, s := range ip.InferenceProfileSummaries {
156 add(s.InferenceProfileID)
157 }
158 if ip.NextToken == "" {
159 break
160 }
161 next = ip.NextToken
162 }
163
164 return ids, nil
165}
166
167func bedrockEmitsText(modalities []string) bool {
168 if len(modalities) == 0 {
169 return true // Unknown: assume usable rather than hide it.
170 }
171 for _, m := range modalities {
172 if strings.EqualFold(m, "TEXT") {
173 return true
174 }
175 }
176 return false
177}
178
179// bedrockSignedControlPlane issues a SigV4-signed request to a Bedrock
180// control-plane endpoint and returns the response body. Non-2xx is an
181// error carrying the status and trimmed body.
182func bedrockSignedControlPlane(ctx context.Context, client *http.Client, creds *bedrockSigV4Creds, region, method, url string, payload []byte) ([]byte, error) {
183 req, err := http.NewRequestWithContext(ctx, method, url, bytesReader(payload))
184 if err != nil {
185 return nil, err
186 }
187 if payload != nil {
188 req.Header.Set("content-type", "application/json")
189 }
190 if err := signSigV4(req, payload, "bedrock", region, creds, time.Now().UTC()); err != nil {
191 return nil, fmt.Errorf("bedrock discover: sign: %w", err)
192 }
193 resp, err := client.Do(req)
194 if err != nil {
195 return nil, fmt.Errorf("bedrock discover: %w", err)
196 }
197 defer resp.Body.Close()
198 b, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
199 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
200 return nil, fmt.Errorf("bedrock discover: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
201 }
202 return b, nil
203}