v0.4.10 5ac0009

discover_bedrock_capture_live_test.go RawHistoryFolder

1package provider
2
3import (
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
49func 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.
104func 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
171func 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
189func 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
203func 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
213func 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}

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