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() { | |||
| 255 | 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 | 266 | if haveOpenRouter { | |
| 259 | 267 | // /models is public; gate on a credential so the picker only | |
| 260 | 268 | // 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 only | 267 | // /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 ( | |||
| 50 | 50 | "net/http" | |
| 51 | 51 | "net/url" | |
| 52 | 52 | "os" | |
| 53 | + | "os/exec" | |
| 53 | 54 | "sort" | |
| 54 | 55 | "strings" | |
| 55 | 56 | "time" | |
| @@ -82,17 +83,13 @@ type bedrockSigV4Creds struct { | |||
| 82 | 83 | // 3. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ optional | |
| 83 | 84 | // AWS_SESSION_TOKEN) -> SigV4 route. | |
| 84 | 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 | 89 | // region defaults to us-east-1 unless AWS_REGION / AWS_DEFAULT_REGION is | |
| 87 | 90 | // set or the baseURL embeds a region. | |
| 88 | 91 | 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() | |
| 96 | 93 | if baseURL == "" { | |
| 97 | 94 | baseURL = "https://bedrock-runtime." + region + ".amazonaws.com" | |
| 98 | 95 | } | |
| @@ -102,36 +99,111 @@ func NewBedrockClient(apiKey, baseURL string) Client { | |||
| 102 | 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 | 140 | token := apiKey | |
| 107 | 141 | if token == "" || token == "<aws>" { | |
| 108 | 142 | token = os.Getenv("AWS_BEARER_TOKEN_BEDROCK") | |
| 109 | 143 | } | |
| 110 | 144 | if token != "" && token != "<aws>" { | |
| 111 | - | c.bearerToken = token | |
| 112 | - | return c | |
| 145 | + | return token, nil | |
| 113 | 146 | } | |
| 114 | 147 | ||
| 115 | - | // SigV4 route: env vars first. | |
| 116 | 148 | ak := os.Getenv("AWS_ACCESS_KEY_ID") | |
| 117 | 149 | sk := os.Getenv("AWS_SECRET_ACCESS_KEY") | |
| 118 | 150 | st := os.Getenv("AWS_SESSION_TOKEN") | |
| 119 | 151 | 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} | |
| 122 | 153 | } | |
| 123 | 154 | ||
| 124 | - | // SigV4 route: ~/.aws/credentials via AWS_PROFILE. | |
| 125 | 155 | if profile := os.Getenv("AWS_PROFILE"); profile != "" { | |
| 126 | 156 | if creds, err := readAWSCredentialsFile(profile); err == nil { | |
| 127 | - | c.sigv4 = creds | |
| 128 | - | return c | |
| 157 | + | return "", creds | |
| 129 | 158 | } | |
| 130 | 159 | } | |
| 131 | 160 | ||
| 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, | |
| 135 | 207 | } | |
| 136 | 208 | } | |
| 137 | 209 | ||
| @@ -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 (+ optional | 83 | // 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 is | 89 | // 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 := apiKey | 140 | 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 = token | 145 | + | 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 = creds | 157 | + | 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 | + | } | |||