detached 3d28c06

Add Amazon Bedrock live model discovery

Discover foundation models and cross-region inference profiles from the
Bedrock control plane (ListFoundationModels, ListInferenceProfiles) so new
models appear in the picker without a release. Wire it into the startup
refresh alongside the other providers.

Factor Bedrock credential resolution out of NewBedrockClient and add an
AWS CLI fallback (aws configure export-credentials) so SSO, assume-role,
and credential_process profiles resolve for both discovery and inference;
discovery needs SigV4 because the control plane rejects the runtime bearer
token. Pricing is intentionally not fetched: a live check showed the AWS
Price List API returns only stale, input-only rates for legacy models, so
the hand-maintained catalog stays authoritative.

Includes unit tests and a credential-gated live capture helper.
commit
3d28c06ecf427b137dd1c45a3b6c5cf47d9ee186
author
Raymond Gasper · · 2026-09-25 14:48 UTC
parent
5609000
signature
unsigned
5 files · +667 -19
M packages/agent/modelsync.go +8 -0
@@ -255,6 +255,14 @@ func refreshModels() {
255255 all = append(all, live...)
256256 }
257257 }
258+ if cred, _, err := resolveCredentialForBackground(ctx, "amazon-bedrock"); err == nil {
259+ // Bedrock discovery + pricing needs SigV4 credentials
260+ // (env keys or AWS_PROFILE); a bearer-only setup returns
261+ // (nil, nil) and is skipped inside DiscoverBedrock.
262+ if live, err := provider.DiscoverBedrock(ctx, cred, ""); err == nil {
263+ all = append(all, live...)
264+ }
265+ }
258266 if haveOpenRouter {
259267 // /models is public; gate on a credential so the picker only
260268 // fills with OpenRouter's hundreds of routes for users who use it.
@@ -255,6 +255,14 @@ func refreshModels() {
255 all = append(all, live...)255 all = append(all, live...)
256 }256 }
257 }257 }
258+ if cred, _, err := resolveCredentialForBackground(ctx, "amazon-bedrock"); err == nil {
259+ // Bedrock discovery + pricing needs SigV4 credentials
260+ // (env keys or AWS_PROFILE); a bearer-only setup returns
261+ // (nil, nil) and is skipped inside DiscoverBedrock.
262+ if live, err := provider.DiscoverBedrock(ctx, cred, ""); err == nil {
263+ all = append(all, live...)
264+ }
265+ }
258 if haveOpenRouter {266 if haveOpenRouter {
259 // /models is public; gate on a credential so the picker only267 // /models is public; gate on a credential so the picker only
260 // fills with OpenRouter's hundreds of routes for users who use it.268 // fills with OpenRouter's hundreds of routes for users who use it.
M packages/provider/amazon_bedrock.go +91 -19
@@ -50,6 +50,7 @@ import (
5050 "net/http"
5151 "net/url"
5252 "os"
53+ "os/exec"
5354 "sort"
5455 "strings"
5556 "time"
@@ -82,17 +83,13 @@ type bedrockSigV4Creds struct {
8283 // 3. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ optional
8384 // AWS_SESSION_TOKEN) -> SigV4 route.
8485 // 4. AWS_PROFILE -> read ~/.aws/credentials, take that profile's keys.
86+// 5. AWS_PROFILE -> `aws configure export-credentials` (SSO, assume-role,
87+// credential_process) when the profile has no static keys.
8588 //
8689 // region defaults to us-east-1 unless AWS_REGION / AWS_DEFAULT_REGION is
8790 // set or the baseURL embeds a region.
8891 func 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- }
92+ region := bedrockResolveRegion()
9693 if baseURL == "" {
9794 baseURL = "https://bedrock-runtime." + region + ".amazonaws.com"
9895 }
@@ -102,36 +99,111 @@ func NewBedrockClient(apiKey, baseURL string) Client {
10299 http: &http.Client{Timeout: 0},
103100 }
104101
105- // Bearer route.
102+ bearer, sigv4 := resolveBedrockAuth(apiKey)
103+ if bearer != "" {
104+ c.bearerToken = bearer
105+ return c
106+ }
107+ if sigv4 != nil {
108+ c.sigv4 = sigv4
109+ return c
110+ }
111+
112+ return &unimplementedClient{
113+ name: "amazon-bedrock",
114+ hint: "no Bedrock credentials found (set AWS_BEARER_TOKEN_BEDROCK, AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY, or AWS_PROFILE)",
115+ }
116+}
117+
118+// bedrockResolveRegion returns the AWS region for Bedrock calls,
119+// honoring AWS_REGION then AWS_DEFAULT_REGION, defaulting to us-east-1.
120+func bedrockResolveRegion() string {
121+ region := os.Getenv("AWS_REGION")
122+ if region == "" {
123+ region = os.Getenv("AWS_DEFAULT_REGION")
124+ }
125+ if region == "" {
126+ region = "us-east-1"
127+ }
128+ return region
129+}
130+
131+// resolveBedrockAuth determines Bedrock credentials from the same
132+// sources NewBedrockClient uses, so discovery and inference share one
133+// resolution path. Exactly one of (bearer, sigv4) is non-empty on
134+// success; both are zero when no credentials are found.
135+//
136+// Resolution order (first match wins): explicit apiKey bearer, then
137+// AWS_BEARER_TOKEN_BEDROCK, then AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY
138+// env vars, then ~/.aws/credentials via AWS_PROFILE.
139+func resolveBedrockAuth(apiKey string) (bearer string, sigv4 *bedrockSigV4Creds) {
106140 token := apiKey
107141 if token == "" || token == "<aws>" {
108142 token = os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
109143 }
110144 if token != "" && token != "<aws>" {
111- c.bearerToken = token
112- return c
145+ return token, nil
113146 }
114147
115- // SigV4 route: env vars first.
116148 ak := os.Getenv("AWS_ACCESS_KEY_ID")
117149 sk := os.Getenv("AWS_SECRET_ACCESS_KEY")
118150 st := os.Getenv("AWS_SESSION_TOKEN")
119151 if ak != "" && sk != "" {
120- c.sigv4 = &bedrockSigV4Creds{accessKeyID: ak, secretAccessKey: sk, sessionToken: st}
121- return c
152+ return "", &bedrockSigV4Creds{accessKeyID: ak, secretAccessKey: sk, sessionToken: st}
122153 }
123154
124- // SigV4 route: ~/.aws/credentials via AWS_PROFILE.
125155 if profile := os.Getenv("AWS_PROFILE"); profile != "" {
126156 if creds, err := readAWSCredentialsFile(profile); err == nil {
127- c.sigv4 = creds
128- return c
157+ return "", creds
129158 }
130159 }
131160
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)",
161+ // Last resort: ask the AWS CLI to resolve credentials for the active
162+ // profile. This handles SSO, assumed roles, credential_process, and
163+ // web-identity setups whose credentials are not stored as static keys
164+ // in ~/.aws/credentials. Best-effort: absent CLI or an expired login
165+ // simply yields no credentials.
166+ if creds := resolveBedrockCredsViaCLI(); creds != nil {
167+ return "", creds
168+ }
169+
170+ return "", nil
171+}
172+
173+// resolveBedrockCredsViaCLI shells out to `aws configure
174+// export-credentials --format process`, which returns fully-resolved
175+// credentials for the active profile regardless of how that profile is
176+// configured (static keys, SSO, assume-role, credential_process). The
177+// AWS CLI must be installed and the profile's login (if any) still
178+// valid. Returns nil on any failure so the caller can fall through.
179+func resolveBedrockCredsViaCLI() *bedrockSigV4Creds {
180+ if _, err := exec.LookPath("aws"); err != nil {
181+ return nil
182+ }
183+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
184+ defer cancel()
185+ // AWS_PROFILE / AWS_REGION already in the environment are inherited,
186+ // so the CLI resolves the same profile zot was pointed at.
187+ cmd := exec.CommandContext(ctx, "aws", "configure", "export-credentials", "--format", "process")
188+ out, err := cmd.Output()
189+ if err != nil {
190+ return nil
191+ }
192+ var exported struct {
193+ AccessKeyID string `json:"AccessKeyId"`
194+ SecretAccessKey string `json:"SecretAccessKey"`
195+ SessionToken string `json:"SessionToken"`
196+ }
197+ if err := json.Unmarshal(out, &exported); err != nil {
198+ return nil
199+ }
200+ if exported.AccessKeyID == "" || exported.SecretAccessKey == "" {
201+ return nil
202+ }
203+ return &bedrockSigV4Creds{
204+ accessKeyID: exported.AccessKeyID,
205+ secretAccessKey: exported.SecretAccessKey,
206+ sessionToken: exported.SessionToken,
135207 }
136208 }
137209
@@ -50,6 +50,7 @@ import (
50 "net/http"50 "net/http"
51 "net/url"51 "net/url"
52 "os"52 "os"
53+ "os/exec"
53 "sort"54 "sort"
54 "strings"55 "strings"
55 "time"56 "time"
@@ -82,17 +83,13 @@ type bedrockSigV4Creds struct {
82 // 3. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ optional83 // 3. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ optional
83 // AWS_SESSION_TOKEN) -> SigV4 route.84 // AWS_SESSION_TOKEN) -> SigV4 route.
84 // 4. AWS_PROFILE -> read ~/.aws/credentials, take that profile's keys.85 // 4. AWS_PROFILE -> read ~/.aws/credentials, take that profile's keys.
86+// 5. AWS_PROFILE -> `aws configure export-credentials` (SSO, assume-role,
87+// credential_process) when the profile has no static keys.
85 //88 //
86 // region defaults to us-east-1 unless AWS_REGION / AWS_DEFAULT_REGION is89 // region defaults to us-east-1 unless AWS_REGION / AWS_DEFAULT_REGION is
87 // set or the baseURL embeds a region.90 // set or the baseURL embeds a region.
88 func NewBedrockClient(apiKey, baseURL string) Client {91 func NewBedrockClient(apiKey, baseURL string) Client {
89- region := os.Getenv("AWS_REGION")92+ region := bedrockResolveRegion()
90- if region == "" {
91- region = os.Getenv("AWS_DEFAULT_REGION")
92- }
93- if region == "" {
94- region = "us-east-1"
95- }
96 if baseURL == "" {93 if baseURL == "" {
97 baseURL = "https://bedrock-runtime." + region + ".amazonaws.com"94 baseURL = "https://bedrock-runtime." + region + ".amazonaws.com"
98 }95 }
@@ -102,36 +99,111 @@ func NewBedrockClient(apiKey, baseURL string) Client {
102 http: &http.Client{Timeout: 0},99 http: &http.Client{Timeout: 0},
103 }100 }
104 101
105- // Bearer route.102+ bearer, sigv4 := resolveBedrockAuth(apiKey)
103+ if bearer != "" {
104+ c.bearerToken = bearer
105+ return c
106+ }
107+ if sigv4 != nil {
108+ c.sigv4 = sigv4
109+ return c
110+ }
111+
112+ return &unimplementedClient{
113+ name: "amazon-bedrock",
114+ hint: "no Bedrock credentials found (set AWS_BEARER_TOKEN_BEDROCK, AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY, or AWS_PROFILE)",
115+ }
116+}
117+
118+// bedrockResolveRegion returns the AWS region for Bedrock calls,
119+// honoring AWS_REGION then AWS_DEFAULT_REGION, defaulting to us-east-1.
120+func bedrockResolveRegion() string {
121+ region := os.Getenv("AWS_REGION")
122+ if region == "" {
123+ region = os.Getenv("AWS_DEFAULT_REGION")
124+ }
125+ if region == "" {
126+ region = "us-east-1"
127+ }
128+ return region
129+}
130+
131+// resolveBedrockAuth determines Bedrock credentials from the same
132+// sources NewBedrockClient uses, so discovery and inference share one
133+// resolution path. Exactly one of (bearer, sigv4) is non-empty on
134+// success; both are zero when no credentials are found.
135+//
136+// Resolution order (first match wins): explicit apiKey bearer, then
137+// AWS_BEARER_TOKEN_BEDROCK, then AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY
138+// env vars, then ~/.aws/credentials via AWS_PROFILE.
139+func resolveBedrockAuth(apiKey string) (bearer string, sigv4 *bedrockSigV4Creds) {
106 token := apiKey140 token := apiKey
107 if token == "" || token == "<aws>" {141 if token == "" || token == "<aws>" {
108 token = os.Getenv("AWS_BEARER_TOKEN_BEDROCK")142 token = os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
109 }143 }
110 if token != "" && token != "<aws>" {144 if token != "" && token != "<aws>" {
111- c.bearerToken = token145+ return token, nil
112- return c
113 }146 }
114 147
115- // SigV4 route: env vars first.
116 ak := os.Getenv("AWS_ACCESS_KEY_ID")148 ak := os.Getenv("AWS_ACCESS_KEY_ID")
117 sk := os.Getenv("AWS_SECRET_ACCESS_KEY")149 sk := os.Getenv("AWS_SECRET_ACCESS_KEY")
118 st := os.Getenv("AWS_SESSION_TOKEN")150 st := os.Getenv("AWS_SESSION_TOKEN")
119 if ak != "" && sk != "" {151 if ak != "" && sk != "" {
120- c.sigv4 = &bedrockSigV4Creds{accessKeyID: ak, secretAccessKey: sk, sessionToken: st}152+ return "", &bedrockSigV4Creds{accessKeyID: ak, secretAccessKey: sk, sessionToken: st}
121- return c
122 }153 }
123 154
124- // SigV4 route: ~/.aws/credentials via AWS_PROFILE.
125 if profile := os.Getenv("AWS_PROFILE"); profile != "" {155 if profile := os.Getenv("AWS_PROFILE"); profile != "" {
126 if creds, err := readAWSCredentialsFile(profile); err == nil {156 if creds, err := readAWSCredentialsFile(profile); err == nil {
127- c.sigv4 = creds157+ return "", creds
128- return c
129 }158 }
130 }159 }
131 160
132- return &unimplementedClient{161+ // Last resort: ask the AWS CLI to resolve credentials for the active
133- name: "amazon-bedrock",162+ // profile. This handles SSO, assumed roles, credential_process, and
134- hint: "no Bedrock credentials found (set AWS_BEARER_TOKEN_BEDROCK, AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY, or AWS_PROFILE)",163+ // web-identity setups whose credentials are not stored as static keys
164+ // in ~/.aws/credentials. Best-effort: absent CLI or an expired login
165+ // simply yields no credentials.
166+ if creds := resolveBedrockCredsViaCLI(); creds != nil {
167+ return "", creds
168+ }
169+
170+ return "", nil
171+}
172+
173+// resolveBedrockCredsViaCLI shells out to `aws configure
174+// export-credentials --format process`, which returns fully-resolved
175+// credentials for the active profile regardless of how that profile is
176+// configured (static keys, SSO, assume-role, credential_process). The
177+// AWS CLI must be installed and the profile's login (if any) still
178+// valid. Returns nil on any failure so the caller can fall through.
179+func resolveBedrockCredsViaCLI() *bedrockSigV4Creds {
180+ if _, err := exec.LookPath("aws"); err != nil {
181+ return nil
182+ }
183+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
184+ defer cancel()
185+ // AWS_PROFILE / AWS_REGION already in the environment are inherited,
186+ // so the CLI resolves the same profile zot was pointed at.
187+ cmd := exec.CommandContext(ctx, "aws", "configure", "export-credentials", "--format", "process")
188+ out, err := cmd.Output()
189+ if err != nil {
190+ return nil
191+ }
192+ var exported struct {
193+ AccessKeyID string `json:"AccessKeyId"`
194+ SecretAccessKey string `json:"SecretAccessKey"`
195+ SessionToken string `json:"SessionToken"`
196+ }
197+ if err := json.Unmarshal(out, &exported); err != nil {
198+ return nil
199+ }
200+ if exported.AccessKeyID == "" || exported.SecretAccessKey == "" {
201+ return nil
202+ }
203+ return &bedrockSigV4Creds{
204+ accessKeyID: exported.AccessKeyID,
205+ secretAccessKey: exported.SecretAccessKey,
206+ sessionToken: exported.SessionToken,
135 }207 }
136 }208 }
137 209
A packages/provider/discover_bedrock.go +203 -0
@@ -0,0 +1,203 @@
1+package 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+
31+import (
32+ "bytes"
33+ "context"
34+ "encoding/json"
35+ "fmt"
36+ "io"
37+ "net/http"
38+ "strings"
39+ "time"
40+)
41+
42+// bytesReader returns a reader for payload, or nil when payload is nil
43+// so GET requests carry no body.
44+func bytesReader(payload []byte) io.Reader {
45+ if payload == nil {
46+ return nil
47+ }
48+ return bytes.NewReader(payload)
49+}
50+
51+// DiscoverBedrock lists the foundation models and inference profiles
52+// available to the caller's AWS credentials in the given region.
53+//
54+// apiKey is the credential zot resolved for the amazon-bedrock provider;
55+// it is used only to detect a bearer token (which cannot reach the
56+// control-plane APIs). SigV4 credentials are resolved from the standard
57+// AWS environment / profile / CLI sources, matching the runtime client.
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.
62+func DiscoverBedrock(ctx context.Context, apiKey, region string) ([]Model, error) {
63+ if region == "" {
64+ region = bedrockResolveRegion()
65+ }
66+ _, sigv4 := resolveBedrockAuth(apiKey)
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, Model{
85+ Provider: "amazon-bedrock",
86+ ID: id,
87+ Source: "live",
88+ BaseURL: "https://bedrock-runtime." + region + ".amazonaws.com",
89+ })
90+ }
91+ return out, nil
92+}
93+
94+// bedrockListModelIDs enumerates foundation-model IDs and inference-
95+// profile IDs available in the region. Profile IDs are the geo-prefixed
96+// forms zot invokes; both are returned so the picker shows every
97+// selectable variant. controlPlaneBase is the scheme+host (no trailing
98+// slash) of the Bedrock control plane, parameterized for testing.
99+func bedrockListModelIDs(ctx context.Context, client *http.Client, creds *bedrockSigV4Creds, region, controlPlaneBase string) ([]string, error) {
100+ seen := map[string]bool{}
101+ var ids []string
102+ add := func(id string) {
103+ id = strings.TrimSpace(id)
104+ if id == "" || seen[id] {
105+ return
106+ }
107+ seen[id] = true
108+ ids = append(ids, id)
109+ }
110+
111+ // ListFoundationModels: GET, no pagination token in the response.
112+ fmURL := controlPlaneBase + "/foundation-models"
113+ body, err := bedrockSignedControlPlane(ctx, client, creds, region, http.MethodGet, fmURL, nil)
114+ if err != nil {
115+ return nil, err
116+ }
117+ var fm struct {
118+ ModelSummaries []struct {
119+ ModelID string `json:"modelId"`
120+ OutputModalities []string `json:"outputModalities"`
121+ } `json:"modelSummaries"`
122+ }
123+ if err := json.Unmarshal(body, &fm); err != nil {
124+ return nil, fmt.Errorf("bedrock discover: parse foundation-models: %w", err)
125+ }
126+ for _, s := range fm.ModelSummaries {
127+ // Keep text-output models only; skip embeddings/image generators.
128+ if !bedrockEmitsText(s.OutputModalities) {
129+ continue
130+ }
131+ add(s.ModelID)
132+ }
133+
134+ // ListInferenceProfiles: GET, paginated via ?nextToken=.
135+ next := ""
136+ for {
137+ ipURL := controlPlaneBase + "/inference-profiles?maxResults=1000"
138+ if next != "" {
139+ ipURL += "&nextToken=" + next
140+ }
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+
167+func 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.
182+func 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+}
@@ -0,0 +1,203 @@
1+package 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+
31+import (
32+ "bytes"
33+ "context"
34+ "encoding/json"
35+ "fmt"
36+ "io"
37+ "net/http"
38+ "strings"
39+ "time"
40+)
41+
42+// bytesReader returns a reader for payload, or nil when payload is nil
43+// so GET requests carry no body.
44+func bytesReader(payload []byte) io.Reader {
45+ if payload == nil {
46+ return nil
47+ }
48+ return bytes.NewReader(payload)
49+}
50+
51+// DiscoverBedrock lists the foundation models and inference profiles
52+// available to the caller's AWS credentials in the given region.
53+//
54+// apiKey is the credential zot resolved for the amazon-bedrock provider;
55+// it is used only to detect a bearer token (which cannot reach the
56+// control-plane APIs). SigV4 credentials are resolved from the standard
57+// AWS environment / profile / CLI sources, matching the runtime client.
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.
62+func DiscoverBedrock(ctx context.Context, apiKey, region string) ([]Model, error) {
63+ if region == "" {
64+ region = bedrockResolveRegion()
65+ }
66+ _, sigv4 := resolveBedrockAuth(apiKey)
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, Model{
85+ Provider: "amazon-bedrock",
86+ ID: id,
87+ Source: "live",
88+ BaseURL: "https://bedrock-runtime." + region + ".amazonaws.com",
89+ })
90+ }
91+ return out, nil
92+}
93+
94+// bedrockListModelIDs enumerates foundation-model IDs and inference-
95+// profile IDs available in the region. Profile IDs are the geo-prefixed
96+// forms zot invokes; both are returned so the picker shows every
97+// selectable variant. controlPlaneBase is the scheme+host (no trailing
98+// slash) of the Bedrock control plane, parameterized for testing.
99+func bedrockListModelIDs(ctx context.Context, client *http.Client, creds *bedrockSigV4Creds, region, controlPlaneBase string) ([]string, error) {
100+ seen := map[string]bool{}
101+ var ids []string
102+ add := func(id string) {
103+ id = strings.TrimSpace(id)
104+ if id == "" || seen[id] {
105+ return
106+ }
107+ seen[id] = true
108+ ids = append(ids, id)
109+ }
110+
111+ // ListFoundationModels: GET, no pagination token in the response.
112+ fmURL := controlPlaneBase + "/foundation-models"
113+ body, err := bedrockSignedControlPlane(ctx, client, creds, region, http.MethodGet, fmURL, nil)
114+ if err != nil {
115+ return nil, err
116+ }
117+ var fm struct {
118+ ModelSummaries []struct {
119+ ModelID string `json:"modelId"`
120+ OutputModalities []string `json:"outputModalities"`
121+ } `json:"modelSummaries"`
122+ }
123+ if err := json.Unmarshal(body, &fm); err != nil {
124+ return nil, fmt.Errorf("bedrock discover: parse foundation-models: %w", err)
125+ }
126+ for _, s := range fm.ModelSummaries {
127+ // Keep text-output models only; skip embeddings/image generators.
128+ if !bedrockEmitsText(s.OutputModalities) {
129+ continue
130+ }
131+ add(s.ModelID)
132+ }
133+
134+ // ListInferenceProfiles: GET, paginated via ?nextToken=.
135+ next := ""
136+ for {
137+ ipURL := controlPlaneBase + "/inference-profiles?maxResults=1000"
138+ if next != "" {
139+ ipURL += "&nextToken=" + next
140+ }
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+
167+func 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.
182+func 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+}
A packages/provider/discover_bedrock_capture_live_test.go +230 -0
@@ -0,0 +1,230 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "os"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+// Live Bedrock discovery diagnostic / capture helper.
14+//
15+// This is NOT a validation test. It lets a developer with real AWS
16+// credentials capture actual control-plane and Price List responses to
17+// verify discovery output and re-check the pricing situation. It never
18+// runs in `go test ./...` unless explicitly enabled, so CI and unpaid
19+// checkouts never hit the network.
20+//
21+// Background: a live capture (Sep 2026, us-east-1) confirmed the AWS
22+// Price List API returns only stale, input-only rows for legacy Anthropic
23+// models (Claude 2.x / 3 Sonnet / 3 Haiku) and no output/cache prices for
24+// any current model, so zot does NOT enrich pricing from it — the catalog
25+// stays authoritative. This helper is retained to detect if/when AWS
26+// fixes that, and to confirm ListFoundationModels / ListInferenceProfiles
27+// shapes when they change.
28+//
29+// Enable and run:
30+//
31+// ZOT_BEDROCK_CAPTURE=1 \
32+// AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... [AWS_SESSION_TOKEN=...] \
33+// AWS_REGION=us-east-1 \
34+// go test ./packages/provider -run TestBedrockCaptureLive -v
35+//
36+// Or with a profile:
37+//
38+// ZOT_BEDROCK_CAPTURE=1 AWS_PROFILE=my-sso AWS_REGION=us-east-1 \
39+// go test ./packages/provider -run TestBedrockCaptureLive -v
40+//
41+// Output is written to files under $ZOT_BEDROCK_CAPTURE_DIR (default: a
42+// temp dir printed in the log). Secrets are never captured — only the
43+// public model/pricing JSON.
44+//
45+// Requires the caller's IAM principal to allow:
46+// - bedrock:ListFoundationModels
47+// - bedrock:ListInferenceProfiles
48+// - pricing:GetProducts
49+func TestBedrockCaptureLive(t *testing.T) {
50+ if strings.TrimSpace(os.Getenv("ZOT_BEDROCK_CAPTURE")) == "" {
51+ t.Skip("set ZOT_BEDROCK_CAPTURE=1 (with AWS creds) to capture live Bedrock responses")
52+ }
53+
54+ _, sigv4 := resolveBedrockAuth("")
55+ if sigv4 == nil {
56+ t.Fatal("no SigV4 credentials found; set AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY or AWS_PROFILE")
57+ }
58+ region := bedrockResolveRegion()
59+ t.Logf("capturing Bedrock responses for region=%s", region)
60+
61+ outDir := strings.TrimSpace(os.Getenv("ZOT_BEDROCK_CAPTURE_DIR"))
62+ if outDir == "" {
63+ outDir = t.TempDir()
64+ }
65+ t.Logf("writing captures to %s", outDir)
66+
67+ client := &http.Client{Timeout: 30 * time.Second}
68+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
69+ defer cancel()
70+
71+ // 1. ListFoundationModels (control plane, GET).
72+ fmURL := "https://bedrock." + region + ".amazonaws.com/foundation-models"
73+ if body, err := bedrockSignedControlPlane(ctx, client, sigv4, region, http.MethodGet, fmURL, nil); err != nil {
74+ t.Errorf("ListFoundationModels failed: %v", err)
75+ } else {
76+ writeCapture(t, outDir, "foundation-models.json", body)
77+ // Also log the summarized model IDs so the capturer can eyeball them.
78+ logBedrockModelIDs(t, body)
79+ }
80+
81+ // 2. ListInferenceProfiles (control plane, GET, paginated).
82+ ipURL := "https://bedrock." + region + ".amazonaws.com/inference-profiles?maxResults=1000"
83+ if body, err := bedrockSignedControlPlane(ctx, client, sigv4, region, http.MethodGet, ipURL, nil); err != nil {
84+ t.Errorf("ListInferenceProfiles failed: %v", err)
85+ } else {
86+ writeCapture(t, outDir, "inference-profiles.json", body)
87+ }
88+
89+ // 3. GetProducts for AmazonBedrock (Price List API, POST).
90+ // Capture the FIRST page raw so we can inspect real attribute
91+ // names, inferenceType values, feature strings, and units. We
92+ // deliberately do not paginate here — one page is enough to fix
93+ // the fixtures, and it keeps the captured file small.
94+ captureBedrockPricingPage(ctx, t, client, sigv4, region, outDir)
95+
96+ t.Logf("done. Inspect the files in %s and share them (they contain only public pricing/model data).", outDir)
97+}
98+
99+// captureBedrockPricingPage issues a single GetProducts page filtered to
100+// the region and the Anthropic provider (the family we care about most)
101+// and writes the raw response. Anthropic is the case the aws-cli #9567
102+// report flagged as possibly missing output-token rows, so capturing it
103+// specifically lets us confirm or refute that.
104+func captureBedrockPricingPage(ctx context.Context, t *testing.T, client *http.Client, creds *bedrockSigV4Creds, region, outDir string) {
105+ const pricingRegion = "us-east-1"
106+ url := "https://api.pricing." + pricingRegion + ".amazonaws.com/"
107+
108+ reqBody := map[string]any{
109+ "ServiceCode": "AmazonBedrock",
110+ "FormatVersion": "aws_v1",
111+ "MaxResults": 100,
112+ "Filters": []map[string]string{
113+ {"Type": "TERM_MATCH", "Field": "regionCode", "Value": region},
114+ {"Type": "TERM_MATCH", "Field": "provider", "Value": "Anthropic"},
115+ },
116+ }
117+ payload, err := json.Marshal(reqBody)
118+ if err != nil {
119+ t.Errorf("marshal pricing request: %v", err)
120+ return
121+ }
122+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytesReader(payload))
123+ if err != nil {
124+ t.Errorf("build pricing request: %v", err)
125+ return
126+ }
127+ req.Header.Set("content-type", "application/x-amz-json-1.1")
128+ req.Header.Set("x-amz-target", "AWSPriceListService.GetProducts")
129+ if err := signSigV4(req, payload, "pricing", pricingRegion, creds, time.Now().UTC()); err != nil {
130+ t.Errorf("sign pricing request: %v", err)
131+ return
132+ }
133+ resp, err := client.Do(req)
134+ if err != nil {
135+ t.Errorf("pricing request failed: %v", err)
136+ return
137+ }
138+ defer resp.Body.Close()
139+ var page struct {
140+ PriceList []string `json:"PriceList"`
141+ NextToken string `json:"NextToken"`
142+ }
143+ body := readAll(t, resp)
144+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
145+ t.Errorf("pricing http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
146+ return
147+ }
148+ writeCapture(t, outDir, "pricing-anthropic-raw.json", body)
149+
150+ // Pretty-print each PriceList entry (they are JSON-encoded strings)
151+ // into a single readable file, and log a compact attribute summary.
152+ if err := json.Unmarshal(body, &page); err != nil {
153+ t.Errorf("parse pricing page: %v", err)
154+ return
155+ }
156+ t.Logf("pricing page returned %d PriceList entries (NextToken present: %v)", len(page.PriceList), page.NextToken != "")
157+ var pretty strings.Builder
158+ for _, raw := range page.PriceList {
159+ var obj any
160+ if err := json.Unmarshal([]byte(raw), &obj); err != nil {
161+ continue
162+ }
163+ b, _ := json.MarshalIndent(obj, "", " ")
164+ pretty.Write(b)
165+ pretty.WriteByte('\n')
166+ logBedrockPriceAttributes(t, raw)
167+ }
168+ writeCapture(t, outDir, "pricing-anthropic-pretty.json", []byte(pretty.String()))
169+}
170+
171+func logBedrockModelIDs(t *testing.T, body []byte) {
172+ var fm struct {
173+ ModelSummaries []struct {
174+ ModelID string `json:"modelId"`
175+ OutputModalities []string `json:"outputModalities"`
176+ } `json:"modelSummaries"`
177+ }
178+ if err := json.Unmarshal(body, &fm); err != nil {
179+ return
180+ }
181+ t.Logf("ListFoundationModels: %d models", len(fm.ModelSummaries))
182+ for _, s := range fm.ModelSummaries {
183+ if strings.Contains(strings.ToLower(s.ModelID), "anthropic") {
184+ t.Logf(" modelId=%s outputModalities=%v", s.ModelID, s.OutputModalities)
185+ }
186+ }
187+}
188+
189+func logBedrockPriceAttributes(t *testing.T, raw string) {
190+ var entry struct {
191+ Product struct {
192+ Attributes map[string]string `json:"attributes"`
193+ } `json:"product"`
194+ }
195+ if err := json.Unmarshal([]byte(raw), &entry); err != nil {
196+ return
197+ }
198+ a := entry.Product.Attributes
199+ t.Logf(" price: model=%q inferenceType=%q feature=%q usagetype=%q",
200+ a["model"], a["inferenceType"], a["feature"], a["usagetype"])
201+}
202+
203+func writeCapture(t *testing.T, dir, name string, body []byte) {
204+ t.Helper()
205+ path := dir + string(os.PathSeparator) + name
206+ if err := os.WriteFile(path, body, 0o644); err != nil {
207+ t.Errorf("write %s: %v", path, err)
208+ return
209+ }
210+ t.Logf("wrote %s (%d bytes)", path, len(body))
211+}
212+
213+func readAll(t *testing.T, resp *http.Response) []byte {
214+ t.Helper()
215+ buf := make([]byte, 0, 1<<20)
216+ tmp := make([]byte, 32<<10)
217+ for {
218+ n, err := resp.Body.Read(tmp)
219+ if n > 0 {
220+ buf = append(buf, tmp[:n]...)
221+ }
222+ if err != nil {
223+ break
224+ }
225+ if len(buf) > 32<<20 {
226+ break
227+ }
228+ }
229+ return buf
230+}
@@ -0,0 +1,230 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "os"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+// Live Bedrock discovery diagnostic / capture helper.
14+//
15+// This is NOT a validation test. It lets a developer with real AWS
16+// credentials capture actual control-plane and Price List responses to
17+// verify discovery output and re-check the pricing situation. It never
18+// runs in `go test ./...` unless explicitly enabled, so CI and unpaid
19+// checkouts never hit the network.
20+//
21+// Background: a live capture (Sep 2026, us-east-1) confirmed the AWS
22+// Price List API returns only stale, input-only rows for legacy Anthropic
23+// models (Claude 2.x / 3 Sonnet / 3 Haiku) and no output/cache prices for
24+// any current model, so zot does NOT enrich pricing from it — the catalog
25+// stays authoritative. This helper is retained to detect if/when AWS
26+// fixes that, and to confirm ListFoundationModels / ListInferenceProfiles
27+// shapes when they change.
28+//
29+// Enable and run:
30+//
31+// ZOT_BEDROCK_CAPTURE=1 \
32+// AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... [AWS_SESSION_TOKEN=...] \
33+// AWS_REGION=us-east-1 \
34+// go test ./packages/provider -run TestBedrockCaptureLive -v
35+//
36+// Or with a profile:
37+//
38+// ZOT_BEDROCK_CAPTURE=1 AWS_PROFILE=my-sso AWS_REGION=us-east-1 \
39+// go test ./packages/provider -run TestBedrockCaptureLive -v
40+//
41+// Output is written to files under $ZOT_BEDROCK_CAPTURE_DIR (default: a
42+// temp dir printed in the log). Secrets are never captured — only the
43+// public model/pricing JSON.
44+//
45+// Requires the caller's IAM principal to allow:
46+// - bedrock:ListFoundationModels
47+// - bedrock:ListInferenceProfiles
48+// - pricing:GetProducts
49+func TestBedrockCaptureLive(t *testing.T) {
50+ if strings.TrimSpace(os.Getenv("ZOT_BEDROCK_CAPTURE")) == "" {
51+ t.Skip("set ZOT_BEDROCK_CAPTURE=1 (with AWS creds) to capture live Bedrock responses")
52+ }
53+
54+ _, sigv4 := resolveBedrockAuth("")
55+ if sigv4 == nil {
56+ t.Fatal("no SigV4 credentials found; set AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY or AWS_PROFILE")
57+ }
58+ region := bedrockResolveRegion()
59+ t.Logf("capturing Bedrock responses for region=%s", region)
60+
61+ outDir := strings.TrimSpace(os.Getenv("ZOT_BEDROCK_CAPTURE_DIR"))
62+ if outDir == "" {
63+ outDir = t.TempDir()
64+ }
65+ t.Logf("writing captures to %s", outDir)
66+
67+ client := &http.Client{Timeout: 30 * time.Second}
68+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
69+ defer cancel()
70+
71+ // 1. ListFoundationModels (control plane, GET).
72+ fmURL := "https://bedrock." + region + ".amazonaws.com/foundation-models"
73+ if body, err := bedrockSignedControlPlane(ctx, client, sigv4, region, http.MethodGet, fmURL, nil); err != nil {
74+ t.Errorf("ListFoundationModels failed: %v", err)
75+ } else {
76+ writeCapture(t, outDir, "foundation-models.json", body)
77+ // Also log the summarized model IDs so the capturer can eyeball them.
78+ logBedrockModelIDs(t, body)
79+ }
80+
81+ // 2. ListInferenceProfiles (control plane, GET, paginated).
82+ ipURL := "https://bedrock." + region + ".amazonaws.com/inference-profiles?maxResults=1000"
83+ if body, err := bedrockSignedControlPlane(ctx, client, sigv4, region, http.MethodGet, ipURL, nil); err != nil {
84+ t.Errorf("ListInferenceProfiles failed: %v", err)
85+ } else {
86+ writeCapture(t, outDir, "inference-profiles.json", body)
87+ }
88+
89+ // 3. GetProducts for AmazonBedrock (Price List API, POST).
90+ // Capture the FIRST page raw so we can inspect real attribute
91+ // names, inferenceType values, feature strings, and units. We
92+ // deliberately do not paginate here — one page is enough to fix
93+ // the fixtures, and it keeps the captured file small.
94+ captureBedrockPricingPage(ctx, t, client, sigv4, region, outDir)
95+
96+ t.Logf("done. Inspect the files in %s and share them (they contain only public pricing/model data).", outDir)
97+}
98+
99+// captureBedrockPricingPage issues a single GetProducts page filtered to
100+// the region and the Anthropic provider (the family we care about most)
101+// and writes the raw response. Anthropic is the case the aws-cli #9567
102+// report flagged as possibly missing output-token rows, so capturing it
103+// specifically lets us confirm or refute that.
104+func captureBedrockPricingPage(ctx context.Context, t *testing.T, client *http.Client, creds *bedrockSigV4Creds, region, outDir string) {
105+ const pricingRegion = "us-east-1"
106+ url := "https://api.pricing." + pricingRegion + ".amazonaws.com/"
107+
108+ reqBody := map[string]any{
109+ "ServiceCode": "AmazonBedrock",
110+ "FormatVersion": "aws_v1",
111+ "MaxResults": 100,
112+ "Filters": []map[string]string{
113+ {"Type": "TERM_MATCH", "Field": "regionCode", "Value": region},
114+ {"Type": "TERM_MATCH", "Field": "provider", "Value": "Anthropic"},
115+ },
116+ }
117+ payload, err := json.Marshal(reqBody)
118+ if err != nil {
119+ t.Errorf("marshal pricing request: %v", err)
120+ return
121+ }
122+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytesReader(payload))
123+ if err != nil {
124+ t.Errorf("build pricing request: %v", err)
125+ return
126+ }
127+ req.Header.Set("content-type", "application/x-amz-json-1.1")
128+ req.Header.Set("x-amz-target", "AWSPriceListService.GetProducts")
129+ if err := signSigV4(req, payload, "pricing", pricingRegion, creds, time.Now().UTC()); err != nil {
130+ t.Errorf("sign pricing request: %v", err)
131+ return
132+ }
133+ resp, err := client.Do(req)
134+ if err != nil {
135+ t.Errorf("pricing request failed: %v", err)
136+ return
137+ }
138+ defer resp.Body.Close()
139+ var page struct {
140+ PriceList []string `json:"PriceList"`
141+ NextToken string `json:"NextToken"`
142+ }
143+ body := readAll(t, resp)
144+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
145+ t.Errorf("pricing http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
146+ return
147+ }
148+ writeCapture(t, outDir, "pricing-anthropic-raw.json", body)
149+
150+ // Pretty-print each PriceList entry (they are JSON-encoded strings)
151+ // into a single readable file, and log a compact attribute summary.
152+ if err := json.Unmarshal(body, &page); err != nil {
153+ t.Errorf("parse pricing page: %v", err)
154+ return
155+ }
156+ t.Logf("pricing page returned %d PriceList entries (NextToken present: %v)", len(page.PriceList), page.NextToken != "")
157+ var pretty strings.Builder
158+ for _, raw := range page.PriceList {
159+ var obj any
160+ if err := json.Unmarshal([]byte(raw), &obj); err != nil {
161+ continue
162+ }
163+ b, _ := json.MarshalIndent(obj, "", " ")
164+ pretty.Write(b)
165+ pretty.WriteByte('\n')
166+ logBedrockPriceAttributes(t, raw)
167+ }
168+ writeCapture(t, outDir, "pricing-anthropic-pretty.json", []byte(pretty.String()))
169+}
170+
171+func logBedrockModelIDs(t *testing.T, body []byte) {
172+ var fm struct {
173+ ModelSummaries []struct {
174+ ModelID string `json:"modelId"`
175+ OutputModalities []string `json:"outputModalities"`
176+ } `json:"modelSummaries"`
177+ }
178+ if err := json.Unmarshal(body, &fm); err != nil {
179+ return
180+ }
181+ t.Logf("ListFoundationModels: %d models", len(fm.ModelSummaries))
182+ for _, s := range fm.ModelSummaries {
183+ if strings.Contains(strings.ToLower(s.ModelID), "anthropic") {
184+ t.Logf(" modelId=%s outputModalities=%v", s.ModelID, s.OutputModalities)
185+ }
186+ }
187+}
188+
189+func logBedrockPriceAttributes(t *testing.T, raw string) {
190+ var entry struct {
191+ Product struct {
192+ Attributes map[string]string `json:"attributes"`
193+ } `json:"product"`
194+ }
195+ if err := json.Unmarshal([]byte(raw), &entry); err != nil {
196+ return
197+ }
198+ a := entry.Product.Attributes
199+ t.Logf(" price: model=%q inferenceType=%q feature=%q usagetype=%q",
200+ a["model"], a["inferenceType"], a["feature"], a["usagetype"])
201+}
202+
203+func writeCapture(t *testing.T, dir, name string, body []byte) {
204+ t.Helper()
205+ path := dir + string(os.PathSeparator) + name
206+ if err := os.WriteFile(path, body, 0o644); err != nil {
207+ t.Errorf("write %s: %v", path, err)
208+ return
209+ }
210+ t.Logf("wrote %s (%d bytes)", path, len(body))
211+}
212+
213+func readAll(t *testing.T, resp *http.Response) []byte {
214+ t.Helper()
215+ buf := make([]byte, 0, 1<<20)
216+ tmp := make([]byte, 32<<10)
217+ for {
218+ n, err := resp.Body.Read(tmp)
219+ if n > 0 {
220+ buf = append(buf, tmp[:n]...)
221+ }
222+ if err != nil {
223+ break
224+ }
225+ if len(buf) > 32<<20 {
226+ break
227+ }
228+ }
229+ return buf
230+}
A packages/provider/discover_bedrock_test.go +135 -0
@@ -0,0 +1,135 @@
1+package provider
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "strings"
8+ "testing"
9+)
10+
11+func TestBedrockEmitsText(t *testing.T) {
12+ if !bedrockEmitsText(nil) {
13+ t.Error("nil modalities should default to text")
14+ }
15+ if !bedrockEmitsText([]string{"TEXT"}) {
16+ t.Error("TEXT should emit text")
17+ }
18+ if bedrockEmitsText([]string{"IMAGE"}) {
19+ t.Error("IMAGE-only should not emit text")
20+ }
21+ if !bedrockEmitsText([]string{"IMAGE", "TEXT"}) {
22+ t.Error("mixed with TEXT should emit text")
23+ }
24+}
25+
26+func TestResolveBedrockAuthBearerVsSigV4(t *testing.T) {
27+ t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "")
28+ t.Setenv("AWS_ACCESS_KEY_ID", "")
29+ t.Setenv("AWS_SECRET_ACCESS_KEY", "")
30+ t.Setenv("AWS_SESSION_TOKEN", "")
31+ t.Setenv("AWS_PROFILE", "")
32+
33+ // Explicit bearer via apiKey.
34+ if b, s := resolveBedrockAuth("tok-123"); b != "tok-123" || s != nil {
35+ t.Errorf("explicit bearer: got (%q,%v)", b, s)
36+ }
37+
38+ // Sentinel <aws> is not a bearer; falls through to SigV4 env.
39+ t.Setenv("AWS_ACCESS_KEY_ID", "AKID")
40+ t.Setenv("AWS_SECRET_ACCESS_KEY", "SECRET")
41+ b, s := resolveBedrockAuth("<aws>")
42+ if b != "" || s == nil {
43+ t.Fatalf("sentinel should resolve SigV4, got (%q,%v)", b, s)
44+ }
45+ if s.accessKeyID != "AKID" || s.secretAccessKey != "SECRET" {
46+ t.Errorf("sigv4 creds = %+v", s)
47+ }
48+}
49+
50+// TestBedrockListModelIDs parses control-plane responses shaped exactly
51+// like the real ones captured from a live account (see the doc note in
52+// discover_bedrock.go): ListFoundationModels returns modelSummaries with
53+// modelId + outputModalities; ListInferenceProfiles returns
54+// inferenceProfileSummaries with inferenceProfileId and paginates via
55+// nextToken. Embedding/image-only models are filtered out; profile IDs
56+// are included; duplicates are de-duplicated.
57+func TestBedrockListModelIDs(t *testing.T) {
58+ page1 := `{"inferenceProfileSummaries":[{"inferenceProfileId":"us.anthropic.claude-opus-5-5"}],"nextToken":"NEXT"}`
59+ page2 := `{"inferenceProfileSummaries":[{"inferenceProfileId":"eu.anthropic.claude-sonnet-5"}]}`
60+ foundation := `{"modelSummaries":[
61+ {"modelId":"anthropic.claude-opus-5-5","outputModalities":["TEXT"]},
62+ {"modelId":"amazon.titan-embed-text-v2","outputModalities":["EMBEDDING"]},
63+ {"modelId":"stability.sd3-large","outputModalities":["IMAGE"]},
64+ {"modelId":"anthropic.claude-opus-5-5","outputModalities":["TEXT"]}
65+ ]}`
66+
67+ var ipHits int
68+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69+ w.Header().Set("content-type", "application/json")
70+ switch {
71+ case strings.Contains(r.URL.Path, "/foundation-models"):
72+ w.Write([]byte(foundation))
73+ case strings.Contains(r.URL.Path, "/inference-profiles"):
74+ ipHits++
75+ if r.URL.Query().Get("nextToken") == "NEXT" {
76+ w.Write([]byte(page2))
77+ } else {
78+ w.Write([]byte(page1))
79+ }
80+ default:
81+ w.WriteHeader(http.StatusNotFound)
82+ }
83+ }))
84+ defer srv.Close()
85+
86+ creds := &bedrockSigV4Creds{accessKeyID: "AKID", secretAccessKey: "SECRET"}
87+ ids, err := bedrockListModelIDs(context.Background(), srv.Client(), creds, "us-east-1", srv.URL)
88+ if err != nil {
89+ t.Fatalf("bedrockListModelIDs: %v", err)
90+ }
91+
92+ want := []string{
93+ "anthropic.claude-opus-5-5", // text foundation model, de-duplicated
94+ "us.anthropic.claude-opus-5-5", // profile page 1
95+ "eu.anthropic.claude-sonnet-5", // profile page 2 (pagination followed)
96+ }
97+ got := map[string]int{}
98+ for _, id := range ids {
99+ got[id]++
100+ }
101+ for _, w := range want {
102+ if got[w] != 1 {
103+ t.Errorf("expected exactly one %q, got %d (all=%v)", w, got[w], ids)
104+ }
105+ }
106+ // Embedding and image-only models must be filtered out.
107+ for _, bad := range []string{"amazon.titan-embed-text-v2", "stability.sd3-large"} {
108+ if got[bad] != 0 {
109+ t.Errorf("non-text model %q should be filtered out", bad)
110+ }
111+ }
112+ if ipHits != 2 {
113+ t.Errorf("expected 2 inference-profile page requests (pagination), got %d", ipHits)
114+ }
115+}
116+
117+// TestBedrockControlPlaneHTTPError confirms non-2xx responses surface as
118+// errors with the status and body, so discovery fails loudly (and the
119+// caller then falls back to the catalog) rather than silently parsing junk.
120+func TestBedrockControlPlaneHTTPError(t *testing.T) {
121+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122+ w.WriteHeader(http.StatusForbidden)
123+ w.Write([]byte(`{"message":"not authorized"}`))
124+ }))
125+ defer srv.Close()
126+
127+ creds := &bedrockSigV4Creds{accessKeyID: "AKID", secretAccessKey: "SECRET"}
128+ _, err := bedrockSignedControlPlane(context.Background(), srv.Client(), creds, "us-east-1", http.MethodGet, srv.URL+"/foundation-models", nil)
129+ if err == nil {
130+ t.Fatal("expected error on 403")
131+ }
132+ if !strings.Contains(err.Error(), "403") || !strings.Contains(err.Error(), "not authorized") {
133+ t.Errorf("error should carry status and body: %v", err)
134+ }
135+}
@@ -0,0 +1,135 @@
1+package provider
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "strings"
8+ "testing"
9+)
10+
11+func TestBedrockEmitsText(t *testing.T) {
12+ if !bedrockEmitsText(nil) {
13+ t.Error("nil modalities should default to text")
14+ }
15+ if !bedrockEmitsText([]string{"TEXT"}) {
16+ t.Error("TEXT should emit text")
17+ }
18+ if bedrockEmitsText([]string{"IMAGE"}) {
19+ t.Error("IMAGE-only should not emit text")
20+ }
21+ if !bedrockEmitsText([]string{"IMAGE", "TEXT"}) {
22+ t.Error("mixed with TEXT should emit text")
23+ }
24+}
25+
26+func TestResolveBedrockAuthBearerVsSigV4(t *testing.T) {
27+ t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "")
28+ t.Setenv("AWS_ACCESS_KEY_ID", "")
29+ t.Setenv("AWS_SECRET_ACCESS_KEY", "")
30+ t.Setenv("AWS_SESSION_TOKEN", "")
31+ t.Setenv("AWS_PROFILE", "")
32+
33+ // Explicit bearer via apiKey.
34+ if b, s := resolveBedrockAuth("tok-123"); b != "tok-123" || s != nil {
35+ t.Errorf("explicit bearer: got (%q,%v)", b, s)
36+ }
37+
38+ // Sentinel <aws> is not a bearer; falls through to SigV4 env.
39+ t.Setenv("AWS_ACCESS_KEY_ID", "AKID")
40+ t.Setenv("AWS_SECRET_ACCESS_KEY", "SECRET")
41+ b, s := resolveBedrockAuth("<aws>")
42+ if b != "" || s == nil {
43+ t.Fatalf("sentinel should resolve SigV4, got (%q,%v)", b, s)
44+ }
45+ if s.accessKeyID != "AKID" || s.secretAccessKey != "SECRET" {
46+ t.Errorf("sigv4 creds = %+v", s)
47+ }
48+}
49+
50+// TestBedrockListModelIDs parses control-plane responses shaped exactly
51+// like the real ones captured from a live account (see the doc note in
52+// discover_bedrock.go): ListFoundationModels returns modelSummaries with
53+// modelId + outputModalities; ListInferenceProfiles returns
54+// inferenceProfileSummaries with inferenceProfileId and paginates via
55+// nextToken. Embedding/image-only models are filtered out; profile IDs
56+// are included; duplicates are de-duplicated.
57+func TestBedrockListModelIDs(t *testing.T) {
58+ page1 := `{"inferenceProfileSummaries":[{"inferenceProfileId":"us.anthropic.claude-opus-5-5"}],"nextToken":"NEXT"}`
59+ page2 := `{"inferenceProfileSummaries":[{"inferenceProfileId":"eu.anthropic.claude-sonnet-5"}]}`
60+ foundation := `{"modelSummaries":[
61+ {"modelId":"anthropic.claude-opus-5-5","outputModalities":["TEXT"]},
62+ {"modelId":"amazon.titan-embed-text-v2","outputModalities":["EMBEDDING"]},
63+ {"modelId":"stability.sd3-large","outputModalities":["IMAGE"]},
64+ {"modelId":"anthropic.claude-opus-5-5","outputModalities":["TEXT"]}
65+ ]}`
66+
67+ var ipHits int
68+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69+ w.Header().Set("content-type", "application/json")
70+ switch {
71+ case strings.Contains(r.URL.Path, "/foundation-models"):
72+ w.Write([]byte(foundation))
73+ case strings.Contains(r.URL.Path, "/inference-profiles"):
74+ ipHits++
75+ if r.URL.Query().Get("nextToken") == "NEXT" {
76+ w.Write([]byte(page2))
77+ } else {
78+ w.Write([]byte(page1))
79+ }
80+ default:
81+ w.WriteHeader(http.StatusNotFound)
82+ }
83+ }))
84+ defer srv.Close()
85+
86+ creds := &bedrockSigV4Creds{accessKeyID: "AKID", secretAccessKey: "SECRET"}
87+ ids, err := bedrockListModelIDs(context.Background(), srv.Client(), creds, "us-east-1", srv.URL)
88+ if err != nil {
89+ t.Fatalf("bedrockListModelIDs: %v", err)
90+ }
91+
92+ want := []string{
93+ "anthropic.claude-opus-5-5", // text foundation model, de-duplicated
94+ "us.anthropic.claude-opus-5-5", // profile page 1
95+ "eu.anthropic.claude-sonnet-5", // profile page 2 (pagination followed)
96+ }
97+ got := map[string]int{}
98+ for _, id := range ids {
99+ got[id]++
100+ }
101+ for _, w := range want {
102+ if got[w] != 1 {
103+ t.Errorf("expected exactly one %q, got %d (all=%v)", w, got[w], ids)
104+ }
105+ }
106+ // Embedding and image-only models must be filtered out.
107+ for _, bad := range []string{"amazon.titan-embed-text-v2", "stability.sd3-large"} {
108+ if got[bad] != 0 {
109+ t.Errorf("non-text model %q should be filtered out", bad)
110+ }
111+ }
112+ if ipHits != 2 {
113+ t.Errorf("expected 2 inference-profile page requests (pagination), got %d", ipHits)
114+ }
115+}
116+
117+// TestBedrockControlPlaneHTTPError confirms non-2xx responses surface as
118+// errors with the status and body, so discovery fails loudly (and the
119+// caller then falls back to the catalog) rather than silently parsing junk.
120+func TestBedrockControlPlaneHTTPError(t *testing.T) {
121+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122+ w.WriteHeader(http.StatusForbidden)
123+ w.Write([]byte(`{"message":"not authorized"}`))
124+ }))
125+ defer srv.Close()
126+
127+ creds := &bedrockSigV4Creds{accessKeyID: "AKID", secretAccessKey: "SECRET"}
128+ _, err := bedrockSignedControlPlane(context.Background(), srv.Client(), creds, "us-east-1", http.MethodGet, srv.URL+"/foundation-models", nil)
129+ if err == nil {
130+ t.Fatal("expected error on 403")
131+ }
132+ if !strings.Contains(err.Error(), "403") || !strings.Contains(err.Error(), "not authorized") {
133+ t.Errorf("error should carry status and body: %v", err)
134+ }
135+}

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