detached 34c0119

Merge branch 'main' into issue-212-oauth-state

commit
34c01198139d3d356ed5d91f5f3a7a1d32f47f09
author
Patric Eckhart · · 2026-09-26 10:51 UTC
committer
GitHub · · 2026-09-26 10:51 UTC
merge of
1390fea + 352bb42 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
12 files · +1246 -58
M README.md +25 -1
@@ -543,7 +543,7 @@ Use `/login` to store API keys or subscription credentials. `/model` only shows
543543 `--list-models` or the `/model` picker shows the full catalog across all built-in providers. Three sources:
544544
545545 - **Catalog**: models baked into zot, covering Claude, GPT/Codex, Gemini/Gemma, Kimi/Moonshot, DeepSeek, Groq-hosted Llama/Gemma/Compound, OpenRouter-routed models, Bedrock model ids, Vertex model ids, Azure OpenAI deployments, Copilot models, and other provider-specific catalog entries.
546-- **Live**: IDs discovered from `GET /v1/models` using your stored API key (cached for 6h in `$ZOT_HOME/models-cache.json`, refreshed in the background on startup).
546+- **Live**: IDs discovered from the provider using your stored credentials — usually `GET /v1/models`, or provider-specific listing APIs for Amazon Bedrock (see [Amazon Bedrock](#amazon-bedrock)). Cached for 6h in `$ZOT_HOME/models-cache.json` and refreshed in the background on startup.
547547 - **Speculative**: IDs that appear in the upstream generator but aren't live on the public API yet. They'll 404 today and start working the moment the provider ships them.
548548
549549 The context meter in the status line uses the model's advertised context window to show how much of it your last turn consumed.
@@ -784,6 +784,30 @@ Access tokens are cached in memory and refreshed on demand.
784784
785785 If none of these are available, zot errors with `vertex: no auth — set GOOGLE_CLOUD_API_KEY or GOOGLE_APPLICATION_CREDENTIALS`.
786786
787+### Amazon Bedrock
788+
789+zot talks to Bedrock's Converse streaming API and supports two credential styles:
790+
791+- **Bedrock API key** (bearer token) via `AWS_BEARER_TOKEN_BEDROCK`. This is the simplest setup: a single opaque string, region-scoped to wherever it was minted. It requires only the `bedrock:CallWithBearerToken` permission.
792+- **SigV4** via `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (plus optional `AWS_SESSION_TOKEN`), an `AWS_PROFILE`, or a default / SSO / assume-role / `credential_process` profile resolved through the AWS CLI. SigV4 (AWS Signature Version 4) is AWS's request-signing scheme: your secret key signs each request locally and only the signature travels over the wire. zot implements it directly so it does not depend on the AWS SDK.
793+
794+If both are present, the bearer token wins for inference, while live model discovery uses SigV4. The region defaults to `AWS_REGION` / `AWS_DEFAULT_REGION`, falling back to `us-east-1`. A `403` on a freshly-copied API key almost always means a region mismatch — set `AWS_REGION` to the region the key was created in, or pass `--base-url https://bedrock-runtime.<region>.amazonaws.com`.
795+
796+#### Catalog vs. live discovery
797+
798+Unlike most providers, Bedrock model **pricing, context windows, and capability flags are hand-maintained** in zot's baked-in catalog. AWS does not expose usable pricing programmatically for current models — its Price List API returns only stale, input-only rates for a few legacy models — so the catalog is authoritative for cost and metadata. This means:
799+
800+- **Bearer-token-only users** still get the full curated Bedrock model list with correct pricing from the catalog. No AWS API listing is attempted.
801+- **Users with SigV4 credentials** additionally get **live model discovery**, even if inference uses a bearer token: on startup, zot lists the foundation models and cross-region inference profiles your account can actually use (via `ListFoundationModels` and `ListInferenceProfiles`) and merges them into the catalog. New Bedrock models therefore appear in `/model` without waiting for a zot release. Discovered IDs already known to the catalog keep their curated pricing; genuinely new IDs show up with placeholder pricing until the catalog is updated.
802+
803+Live discovery requires SigV4 because the listing APIs live on the Bedrock control plane, which rejects the runtime bearer token. It runs in the background at startup and is cached for 6h like other providers, so `/model` reflects the latest list without any manual refresh. If discovery fails (no SigV4 creds, expired SSO login, missing IAM permissions), zot silently falls back to the catalog. Discovery needs `bedrock:ListFoundationModels` and `bedrock:ListInferenceProfiles`.
804+
805+#### Cross-region pricing caveat
806+
807+Bedrock model IDs carry a routing prefix: `global.` (global cross-region inference), a geography such as `us.` / `eu.` / `apac.` / `au.` / `jp.` (geo cross-region inference), or none (in-region). zot's catalog prices **every** variant at the model's base (US) rate.
808+
809+AWS charges a surcharge for **geo cross-region inference** in some regions — for example, Anthropic models routed through the Asia Pacific (Melbourne) geo profile cost about 10% more than the base rate. zot does **not** yet capture these per-region uplifts, so if you use a geo-prefixed profile in a region that charges the surcharge, zot's reported cost may be **lower than your actual AWS bill**. Global cross-region and plain in-region usage match the base rate and are unaffected. The context/usage accounting is still correct; only the dollar figure can under-report in this edge case. If you need exact costs, check the per-region table on the [Amazon Bedrock pricing page](https://aws.amazon.com/bedrock/pricing/).
810+
787811 ### Local models with ollama
788812
789813 zot works with [ollama](https://ollama.com) out of the box. Ollama serves an OpenAI-compatible API locally, so any model you have pulled works with zot.
@@ -543,7 +543,7 @@ Use `/login` to store API keys or subscription credentials. `/model` only shows
543 `--list-models` or the `/model` picker shows the full catalog across all built-in providers. Three sources:543 `--list-models` or the `/model` picker shows the full catalog across all built-in providers. Three sources:
544 544
545 - **Catalog**: models baked into zot, covering Claude, GPT/Codex, Gemini/Gemma, Kimi/Moonshot, DeepSeek, Groq-hosted Llama/Gemma/Compound, OpenRouter-routed models, Bedrock model ids, Vertex model ids, Azure OpenAI deployments, Copilot models, and other provider-specific catalog entries.545 - **Catalog**: models baked into zot, covering Claude, GPT/Codex, Gemini/Gemma, Kimi/Moonshot, DeepSeek, Groq-hosted Llama/Gemma/Compound, OpenRouter-routed models, Bedrock model ids, Vertex model ids, Azure OpenAI deployments, Copilot models, and other provider-specific catalog entries.
546-- **Live**: IDs discovered from `GET /v1/models` using your stored API key (cached for 6h in `$ZOT_HOME/models-cache.json`, refreshed in the background on startup).546+- **Live**: IDs discovered from the provider using your stored credentials — usually `GET /v1/models`, or provider-specific listing APIs for Amazon Bedrock (see [Amazon Bedrock](#amazon-bedrock)). Cached for 6h in `$ZOT_HOME/models-cache.json` and refreshed in the background on startup.
547 - **Speculative**: IDs that appear in the upstream generator but aren't live on the public API yet. They'll 404 today and start working the moment the provider ships them.547 - **Speculative**: IDs that appear in the upstream generator but aren't live on the public API yet. They'll 404 today and start working the moment the provider ships them.
548 548
549 The context meter in the status line uses the model's advertised context window to show how much of it your last turn consumed.549 The context meter in the status line uses the model's advertised context window to show how much of it your last turn consumed.
@@ -784,6 +784,30 @@ Access tokens are cached in memory and refreshed on demand.
784 784
785 If none of these are available, zot errors with `vertex: no auth — set GOOGLE_CLOUD_API_KEY or GOOGLE_APPLICATION_CREDENTIALS`.785 If none of these are available, zot errors with `vertex: no auth — set GOOGLE_CLOUD_API_KEY or GOOGLE_APPLICATION_CREDENTIALS`.
786 786
787+### Amazon Bedrock
788+
789+zot talks to Bedrock's Converse streaming API and supports two credential styles:
790+
791+- **Bedrock API key** (bearer token) via `AWS_BEARER_TOKEN_BEDROCK`. This is the simplest setup: a single opaque string, region-scoped to wherever it was minted. It requires only the `bedrock:CallWithBearerToken` permission.
792+- **SigV4** via `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (plus optional `AWS_SESSION_TOKEN`), an `AWS_PROFILE`, or a default / SSO / assume-role / `credential_process` profile resolved through the AWS CLI. SigV4 (AWS Signature Version 4) is AWS's request-signing scheme: your secret key signs each request locally and only the signature travels over the wire. zot implements it directly so it does not depend on the AWS SDK.
793+
794+If both are present, the bearer token wins for inference, while live model discovery uses SigV4. The region defaults to `AWS_REGION` / `AWS_DEFAULT_REGION`, falling back to `us-east-1`. A `403` on a freshly-copied API key almost always means a region mismatch — set `AWS_REGION` to the region the key was created in, or pass `--base-url https://bedrock-runtime.<region>.amazonaws.com`.
795+
796+#### Catalog vs. live discovery
797+
798+Unlike most providers, Bedrock model **pricing, context windows, and capability flags are hand-maintained** in zot's baked-in catalog. AWS does not expose usable pricing programmatically for current models — its Price List API returns only stale, input-only rates for a few legacy models — so the catalog is authoritative for cost and metadata. This means:
799+
800+- **Bearer-token-only users** still get the full curated Bedrock model list with correct pricing from the catalog. No AWS API listing is attempted.
801+- **Users with SigV4 credentials** additionally get **live model discovery**, even if inference uses a bearer token: on startup, zot lists the foundation models and cross-region inference profiles your account can actually use (via `ListFoundationModels` and `ListInferenceProfiles`) and merges them into the catalog. New Bedrock models therefore appear in `/model` without waiting for a zot release. Discovered IDs already known to the catalog keep their curated pricing; genuinely new IDs show up with placeholder pricing until the catalog is updated.
802+
803+Live discovery requires SigV4 because the listing APIs live on the Bedrock control plane, which rejects the runtime bearer token. It runs in the background at startup and is cached for 6h like other providers, so `/model` reflects the latest list without any manual refresh. If discovery fails (no SigV4 creds, expired SSO login, missing IAM permissions), zot silently falls back to the catalog. Discovery needs `bedrock:ListFoundationModels` and `bedrock:ListInferenceProfiles`.
804+
805+#### Cross-region pricing caveat
806+
807+Bedrock model IDs carry a routing prefix: `global.` (global cross-region inference), a geography such as `us.` / `eu.` / `apac.` / `au.` / `jp.` (geo cross-region inference), or none (in-region). zot's catalog prices **every** variant at the model's base (US) rate.
808+
809+AWS charges a surcharge for **geo cross-region inference** in some regions — for example, Anthropic models routed through the Asia Pacific (Melbourne) geo profile cost about 10% more than the base rate. zot does **not** yet capture these per-region uplifts, so if you use a geo-prefixed profile in a region that charges the surcharge, zot's reported cost may be **lower than your actual AWS bill**. Global cross-region and plain in-region usage match the base rate and are unaffected. The context/usage accounting is still correct; only the dollar figure can under-report in this edge case. If you need exact costs, check the per-region table on the [Amazon Bedrock pricing page](https://aws.amazon.com/bedrock/pricing/).
810+
787 ### Local models with ollama811 ### Local models with ollama
788 812
789 zot works with [ollama](https://ollama.com) out of the box. Ollama serves an OpenAI-compatible API locally, so any model you have pulled works with zot.813 zot works with [ollama](https://ollama.com) out of the box. Ollama serves an OpenAI-compatible API locally, so any model you have pulled works with zot.
M packages/agent/build.go +6 -0
@@ -596,6 +596,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
596596 // models like ollama don't need real API keys).
597597 if resolvedModel.BaseURL != "" && credErr != nil {
598598 cred = "ollama"
599+ if provName == "amazon-bedrock" {
600+ // The default AWS CLI profile is resolved by the Bedrock client,
601+ // not the general credential resolver. Never pass the local-model
602+ // placeholder as a bearer token.
603+ cred = "<aws>"
604+ }
599605 credErr = nil
600606 requireCred = false
601607 }
@@ -596,6 +596,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
596 // models like ollama don't need real API keys).596 // models like ollama don't need real API keys).
597 if resolvedModel.BaseURL != "" && credErr != nil {597 if resolvedModel.BaseURL != "" && credErr != nil {
598 cred = "ollama"598 cred = "ollama"
599+ if provName == "amazon-bedrock" {
600+ // The default AWS CLI profile is resolved by the Bedrock client,
601+ // not the general credential resolver. Never pass the local-model
602+ // placeholder as a bearer token.
603+ cred = "<aws>"
604+ }
599 credErr = nil605 credErr = nil
600 requireCred = false606 requireCred = false
601 }607 }
M packages/agent/modelsync.go +6 -0
@@ -255,6 +255,12 @@ func refreshModels() {
255255 all = append(all, live...)
256256 }
257257 }
258+ // Bedrock resolves SigV4 credentials independently of inference auth.
259+ // A default AWS CLI profile can be usable even when zot has no
260+ // Bedrock credential registered with its general resolver.
261+ if live, err := provider.DiscoverBedrock(ctx, ""); err == nil {
262+ all = append(all, live...)
263+ }
258264 if haveOpenRouter {
259265 // /models is public; gate on a credential so the picker only
260266 // fills with OpenRouter's hundreds of routes for users who use it.
@@ -255,6 +255,12 @@ func refreshModels() {
255 all = append(all, live...)255 all = append(all, live...)
256 }256 }
257 }257 }
258+ // Bedrock resolves SigV4 credentials independently of inference auth.
259+ // A default AWS CLI profile can be usable even when zot has no
260+ // Bedrock credential registered with its general resolver.
261+ if live, err := provider.DiscoverBedrock(ctx, ""); err == nil {
262+ all = append(all, live...)
263+ }
258 if haveOpenRouter {264 if haveOpenRouter {
259 // /models is public; gate on a credential so the picker only265 // /models is public; gate on a credential so the picker only
260 // fills with OpenRouter's hundreds of routes for users who use it.266 // fills with OpenRouter's hundreds of routes for users who use it.
M packages/agent/modelsync_test.go +52 -0
@@ -4,16 +4,68 @@ import (
44 "context"
55 "encoding/json"
66 "fmt"
7+ "io"
78 "net/http"
89 "net/http/httptest"
910 "os"
1011 "path/filepath"
12+ "runtime"
13+ "strings"
1114 "testing"
1215 "time"
1316
1417 "github.com/patriceckhart/zot/packages/provider"
1518 )
1619
20+func TestRefreshModelsBedrockWithDefaultCLIProfile(t *testing.T) {
21+ if runtime.GOOS == "windows" {
22+ t.Skip("test uses a shell script to simulate the AWS CLI")
23+ }
24+ t.Setenv("ZOT_HOME", t.TempDir())
25+ t.Setenv("AWS_PROFILE", "")
26+ t.Setenv("AWS_ACCESS_KEY_ID", "")
27+ t.Setenv("AWS_SECRET_ACCESS_KEY", "")
28+ t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "")
29+ for _, name := range []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "KIMI_API_KEY", "GOOGLE_API_KEY", "OPENROUTER_API_KEY", "GONDOLA_API_KEY", "YOLO_AUTO_API_KEY"} {
30+ t.Setenv(name, "")
31+ }
32+ bin := t.TempDir()
33+ script := "#!/bin/sh\nprintf '%s\\n' '{\"AccessKeyId\":\"AKID\",\"SecretAccessKey\":\"SECRET\"}'\n"
34+ if err := os.WriteFile(filepath.Join(bin, "aws"), []byte(script), 0o755); err != nil {
35+ t.Fatal(err)
36+ }
37+ t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
38+ orig := http.DefaultTransport
39+ t.Cleanup(func() {
40+ http.DefaultTransport = orig
41+ provider.SetLiveModels(nil)
42+ })
43+ http.DefaultTransport = yoloAutoTestTransport(func(r *http.Request) (*http.Response, error) {
44+ var body string
45+ switch r.URL.Path {
46+ case "/foundation-models":
47+ body = `{"modelSummaries":[{"modelId":"example.cli-only-model","outputModalities":["TEXT"]}]}`
48+ case "/inference-profiles":
49+ body = `{"inferenceProfileSummaries":[]}`
50+ default:
51+ return nil, fmt.Errorf("unexpected discovery request: %s", r.URL)
52+ }
53+ return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
54+ })
55+
56+ refreshModels()
57+ if _, err := provider.FindModel("amazon-bedrock", "example.cli-only-model"); err != nil {
58+ t.Fatal("default AWS CLI profile was not discovered:", err)
59+ }
60+ resolved, err := Resolve(Args{Provider: "amazon-bedrock", Model: "example.cli-only-model"}, true)
61+ if err != nil {
62+ t.Fatal("default AWS CLI profile could not resolve for inference:", err)
63+ }
64+ if resolved.Credential != "<aws>" {
65+ t.Fatalf("inference credential = %q, want AWS sentinel", resolved.Credential)
66+ }
67+}
68+
1769 func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) {
1870 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1971 if r.URL.Path != "/models" {
@@ -4,16 +4,68 @@ import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6 "fmt"6 "fmt"
7+ "io"
7 "net/http"8 "net/http"
8 "net/http/httptest"9 "net/http/httptest"
9 "os"10 "os"
10 "path/filepath"11 "path/filepath"
12+ "runtime"
13+ "strings"
11 "testing"14 "testing"
12 "time"15 "time"
13 16
14 "github.com/patriceckhart/zot/packages/provider"17 "github.com/patriceckhart/zot/packages/provider"
15 )18 )
16 19
20+func TestRefreshModelsBedrockWithDefaultCLIProfile(t *testing.T) {
21+ if runtime.GOOS == "windows" {
22+ t.Skip("test uses a shell script to simulate the AWS CLI")
23+ }
24+ t.Setenv("ZOT_HOME", t.TempDir())
25+ t.Setenv("AWS_PROFILE", "")
26+ t.Setenv("AWS_ACCESS_KEY_ID", "")
27+ t.Setenv("AWS_SECRET_ACCESS_KEY", "")
28+ t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "")
29+ for _, name := range []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "KIMI_API_KEY", "GOOGLE_API_KEY", "OPENROUTER_API_KEY", "GONDOLA_API_KEY", "YOLO_AUTO_API_KEY"} {
30+ t.Setenv(name, "")
31+ }
32+ bin := t.TempDir()
33+ script := "#!/bin/sh\nprintf '%s\\n' '{\"AccessKeyId\":\"AKID\",\"SecretAccessKey\":\"SECRET\"}'\n"
34+ if err := os.WriteFile(filepath.Join(bin, "aws"), []byte(script), 0o755); err != nil {
35+ t.Fatal(err)
36+ }
37+ t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
38+ orig := http.DefaultTransport
39+ t.Cleanup(func() {
40+ http.DefaultTransport = orig
41+ provider.SetLiveModels(nil)
42+ })
43+ http.DefaultTransport = yoloAutoTestTransport(func(r *http.Request) (*http.Response, error) {
44+ var body string
45+ switch r.URL.Path {
46+ case "/foundation-models":
47+ body = `{"modelSummaries":[{"modelId":"example.cli-only-model","outputModalities":["TEXT"]}]}`
48+ case "/inference-profiles":
49+ body = `{"inferenceProfileSummaries":[]}`
50+ default:
51+ return nil, fmt.Errorf("unexpected discovery request: %s", r.URL)
52+ }
53+ return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
54+ })
55+
56+ refreshModels()
57+ if _, err := provider.FindModel("amazon-bedrock", "example.cli-only-model"); err != nil {
58+ t.Fatal("default AWS CLI profile was not discovered:", err)
59+ }
60+ resolved, err := Resolve(Args{Provider: "amazon-bedrock", Model: "example.cli-only-model"}, true)
61+ if err != nil {
62+ t.Fatal("default AWS CLI profile could not resolve for inference:", err)
63+ }
64+ if resolved.Credential != "<aws>" {
65+ t.Fatalf("inference credential = %q, want AWS sentinel", resolved.Credential)
66+ }
67+}
68+
17 func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) {69 func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) {
18 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {70 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19 if r.URL.Path != "/models" {71 if r.URL.Path != "/models" {
M packages/agent/modes/interactive.go +14 -0
@@ -1411,6 +1411,20 @@ func (i *Interactive) redraw() {
14111411 case i.telegramDialog.Active():
14121412 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
14131413 case i.settingsDialog.Active():
1414+ // Reserve rows for the editor, the status line, the blank
1415+ // separators, and one chat row so the settings list scrolls
1416+ // inside the dialog when the terminal is short instead of
1417+ // having its top entries clipped off and unreachable.
1418+ // Mirrors the reservation made for the session picker above.
1419+ _, rows := i.cfg.Terminal.Size()
1420+ avail := rows - 12
1421+ // Floor at the frame chrome (header + hint + rule) plus one
1422+ // list row so even a hand-sized terminal draws the row the
1423+ // cursor is on rather than nothing.
1424+ if avail < 4 {
1425+ avail = 4
1426+ }
1427+ i.settingsDialog.MaxRows = avail
14141428 dialog = i.settingsDialog.Render(i.cfg.Theme, cols)
14151429 case i.sessionOpsDialog.Active():
14161430 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
@@ -1411,6 +1411,20 @@ func (i *Interactive) redraw() {
1411 case i.telegramDialog.Active():1411 case i.telegramDialog.Active():
1412 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)1412 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
1413 case i.settingsDialog.Active():1413 case i.settingsDialog.Active():
1414+ // Reserve rows for the editor, the status line, the blank
1415+ // separators, and one chat row so the settings list scrolls
1416+ // inside the dialog when the terminal is short instead of
1417+ // having its top entries clipped off and unreachable.
1418+ // Mirrors the reservation made for the session picker above.
1419+ _, rows := i.cfg.Terminal.Size()
1420+ avail := rows - 12
1421+ // Floor at the frame chrome (header + hint + rule) plus one
1422+ // list row so even a hand-sized terminal draws the row the
1423+ // cursor is on rather than nothing.
1424+ if avail < 4 {
1425+ avail = 4
1426+ }
1427+ i.settingsDialog.MaxRows = avail
1414 dialog = i.settingsDialog.Render(i.cfg.Theme, cols)1428 dialog = i.settingsDialog.Render(i.cfg.Theme, cols)
1415 case i.sessionOpsDialog.Active():1429 case i.sessionOpsDialog.Active():
1416 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)1430 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
M packages/agent/modes/settings_dialog.go +202 -35
@@ -1,6 +1,7 @@
11 package modes
22
33 import (
4+ "fmt"
45 "strings"
56
67 "github.com/mattn/go-runewidth"
@@ -18,6 +19,18 @@ type settingsDialog struct {
1819 optionCursor int
1920 parentItems []settingsItem
2021 parentCursor int
22+
23+ // MaxRows caps how many rows the dialog renders. The host sets it
24+ // from the terminal height before each render; zero renders the
25+ // list in full. A list taller than the cap scrolls in a window
26+ // that follows the cursor so entries never disappear off-screen.
27+ MaxRows int
28+
29+ // viewTop is the index of the first entry drawn in the current
30+ // window. It survives across renders so the window only moves when
31+ // the cursor pushes past one of its edges, and is reset whenever
32+ // the dialog swaps between the item list and an option list.
33+ viewTop int
2134 }
2235
2336 type settingsItem struct {
@@ -62,6 +75,7 @@ func (d *settingsDialog) Open(items []settingsItem) bool {
6275 d.optionCursor = 0
6376 d.parentItems = nil
6477 d.parentCursor = 0
78+ d.viewTop = 0
6579 d.active = true
6680 return true
6781 }
@@ -81,6 +95,7 @@ func (d *settingsDialog) OpenDirectOption(item settingsItem) bool {
8195 }
8296 d.parentItems = nil
8397 d.parentCursor = 0
98+ d.viewTop = 0
8499 d.active = true
85100 return true
86101 }
@@ -90,6 +105,7 @@ func (d *settingsDialog) Close() {
90105 d.selecting = false
91106 d.direct = false
92107 d.parentItems = nil
108+ d.viewTop = 0
93109 }
94110 func (d *settingsDialog) Active() bool { return d != nil && d.active }
95111
@@ -119,6 +135,7 @@ func (d *settingsDialog) HandleKey(k tui.Key) settingsAction {
119135 d.cursor = d.parentCursor
120136 d.parentItems = nil
121137 d.parentCursor = 0
138+ d.viewTop = 0
122139 d.title = "settings"
123140 return settingsAction{}
124141 }
@@ -151,6 +168,7 @@ func (d *settingsDialog) handleOptionKey(k tui.Key) settingsAction {
151168 return settingsAction{Close: true}
152169 }
153170 d.selecting = false
171+ d.viewTop = 0
154172 case tui.KeyEnter:
155173 return d.selectCurrentOption()
156174 case tui.KeyRune:
@@ -188,6 +206,7 @@ func (d *settingsDialog) toggleCurrent() settingsAction {
188206 d.items = it.children
189207 d.cursor = 0
190208 d.optionCursor = 0
209+ d.viewTop = 0
191210 d.title = "settings: " + it.label
192211 return settingsAction{}
193212 }
@@ -197,6 +216,7 @@ func (d *settingsDialog) toggleCurrent() settingsAction {
197216 d.optionCursor = 0
198217 }
199218 d.selecting = true
219+ d.viewTop = 0
200220 return settingsAction{}
201221 }
202222 it.value = !it.value
@@ -220,6 +240,7 @@ func (d *settingsDialog) selectCurrentOption() settingsAction {
220240 it.choice = d.optionCursor
221241 d.items[d.cursor] = it
222242 d.selecting = false
243+ d.viewTop = 0
223244 action := settingsAction{Toggle: true, Key: it.key, StringValue: it.options[it.choice].value}
224245 if d.direct {
225246 d.Close()
@@ -242,39 +263,175 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string {
242263 } else {
243264 lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:"))
244265 }
245- for i, it := range d.items {
246- box := "[ ]"
247- if it.value {
248- box = "[✓]"
249- }
250- plain := " " + box + " " + it.label
251- if it.picker || len(it.children) > 0 {
252- box = "[→]"
253- plain = " " + box + " " + it.label
254- } else if len(it.options) > 0 {
255- box = "[→]"
256- if it.choice < 0 || it.choice >= len(it.options) {
257- it.choice = 0
258- }
259- plain = " " + box + " " + it.label + ": " + it.options[it.choice].label
260- }
261- if it.hint != "" {
262- plain += " " + th.FG256(th.Muted, "("+it.hint+")")
266+
267+ // Render every entry to its own block up front so the row window
268+ // can count wrapped descriptions, then draw only the blocks that
269+ // fit the budget. Windowing keeps the cursor entry on screen: a
270+ // list taller than the terminal scrolls instead of having its top
271+ // entries clipped off and unreachable.
272+ blocks := make([][]string, len(d.items))
273+ heights := make([]int, len(d.items))
274+ for i := range d.items {
275+ blocks[i] = d.itemBlock(th, width, i)
276+ heights[i] = len(blocks[i])
277+ }
278+ budget := d.rowBudget(len(lines) + 1) // one row for the closing rule
279+ start, end := d.windowBlocks(heights, d.cursor, budget)
280+ lines = append(lines, windowLines(th, blocks, start, end, budget)...)
281+ lines = append(lines, frameRule(th, width))
282+ return lines
283+}
284+
285+// itemBlock renders one entry as its label row followed by any wrapped
286+// description rows. Keeping both in one block lets the row window
287+// count the description and trim it when space runs out without losing
288+// the label row the cursor highlight sits on.
289+func (d *settingsDialog) itemBlock(th tui.Theme, width, idx int) []string {
290+ it := d.items[idx]
291+ box := "[ ]"
292+ if it.value {
293+ box = "[✓]"
294+ }
295+ plain := " " + box + " " + it.label
296+ if it.picker || len(it.children) > 0 {
297+ box = "[→]"
298+ plain = " " + box + " " + it.label
299+ } else if len(it.options) > 0 {
300+ box = "[→]"
301+ if it.choice < 0 || it.choice >= len(it.options) {
302+ it.choice = 0
263303 }
264- if it.disabled {
265- lines = append(lines, th.FG256(th.Muted, plain))
266- } else if i == d.cursor {
267- lines = append(lines, th.PadHighlight(plain, width))
268- } else {
269- lines = append(lines, plain)
304+ plain = " " + box + " " + it.label + ": " + it.options[it.choice].label
305+ }
306+ if it.hint != "" {
307+ plain += " " + th.FG256(th.Muted, "("+it.hint+")")
308+ }
309+ line := plain
310+ switch {
311+ case it.disabled:
312+ line = th.FG256(th.Muted, plain)
313+ case idx == d.cursor:
314+ line = th.PadHighlight(plain, width)
315+ }
316+ block := []string{line}
317+ for _, desc := range wrapSettingDescription(it.desc, width, 6) {
318+ block = append(block, th.FG256(th.Muted, desc))
319+ }
320+ return block
321+}
322+
323+// rowBudget returns the rows left for the window after the fixed rows
324+// around it (frame header, hint, closing rule) are counted against
325+// MaxRows. Zero means unbounded.
326+func (d *settingsDialog) rowBudget(fixed int) int {
327+ if d.MaxRows <= 0 {
328+ return 0
329+ }
330+ if budget := d.MaxRows - fixed; budget > 0 {
331+ return budget
332+ }
333+ return 1
334+}
335+
336+// windowBlocks returns the [start, end) range of entry blocks to draw
337+// so the cursor entry stays visible. Heights are the rendered row
338+// counts per entry, budget is the rows available for the blocks plus
339+// the "more above/below" markers, and zero means windowing is off.
340+func (d *settingsDialog) windowBlocks(heights []int, cursor, budget int) (start, end int) {
341+ total := len(heights)
342+ if total == 0 {
343+ return 0, 0
344+ }
345+ if budget <= 0 {
346+ d.viewTop = 0
347+ return 0, total
348+ }
349+ if cursor < 0 {
350+ cursor = 0
351+ }
352+ if cursor >= total {
353+ cursor = total - 1
354+ }
355+ rows := 0
356+ for _, h := range heights {
357+ rows += h
358+ }
359+ if rows <= budget {
360+ d.viewTop = 0
361+ return 0, total
362+ }
363+ // Entries are hidden above and/or below, so hold back a row for
364+ // the markers. On a terminal too short for the cursor entry plus
365+ // markers, drop the reservation: the highlighted row matters more
366+ // than the hint that more entries exist.
367+ inner := budget - 2
368+ if inner < heights[cursor] {
369+ inner = budget
370+ }
371+ return d.anchorBlocks(heights, cursor, inner)
372+}
373+
374+// anchorBlocks returns the block range of combined height <= budget
375+// that keeps the cursor entry visible. The window extends downward
376+// from the persistent viewTop; when the cursor moves past either edge
377+// the window re-anchors so the cursor entry becomes the first or last
378+// visible one. viewTop is updated for the next render so the window
379+// stays put while the cursor moves inside it.
380+func (d *settingsDialog) anchorBlocks(heights []int, cursor, budget int) (start, end int) {
381+ total := len(heights)
382+ start = d.viewTop
383+ if start > cursor || start >= total {
384+ start = cursor
385+ }
386+ used := 0
387+ for end = start; end < total && (end == start || used+heights[end] <= budget); end++ {
388+ used += heights[end]
389+ }
390+ if cursor >= end {
391+ // The cursor fell past the bottom edge: re-anchor on it and
392+ // fill the remaining budget with entries above.
393+ start, end = cursor, cursor+1
394+ used = heights[cursor]
395+ for start > 0 && used+heights[start-1] <= budget {
396+ start--
397+ used += heights[start]
270398 }
271- if it.desc != "" {
272- for _, desc := range wrapSettingDescription(it.desc, width, 6) {
273- lines = append(lines, th.FG256(th.Muted, desc))
399+ }
400+ d.viewTop = start
401+ return start, end
402+}
403+
404+// windowLines renders the [start, end) slice of entry blocks, framed by
405+// muted "more above/below" markers when entries are hidden on either
406+// side. Blocks are trimmed so the result never uses more than budget
407+// rows; budget <= 0 means unbounded.
408+func windowLines(th tui.Theme, blocks [][]string, start, end, budget int) []string {
409+ unbounded := budget <= 0
410+ remaining := budget
411+ var lines []string
412+ // Spend a row on the above marker only when the first block still
413+ // gets a row afterwards: on a terminal this short the highlight
414+ // matters more than the hint that entries are hidden.
415+ if start > 0 && (unbounded || remaining > 1) {
416+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", start)))
417+ remaining--
418+ }
419+ for i := start; i < end; i++ {
420+ block := blocks[i]
421+ if !unbounded {
422+ if remaining <= 0 {
423+ break
424+ }
425+ if len(block) > remaining {
426+ block = block[:remaining]
274427 }
275428 }
429+ lines = append(lines, block...)
430+ remaining -= len(block)
431+ }
432+ if end < len(blocks) && (unbounded || remaining > 0) {
433+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", len(blocks)-end)))
276434 }
277- lines = append(lines, frameRule(th, width))
278435 return lines
279436 }
280437
@@ -289,27 +446,37 @@ func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string {
289446 title = d.title
290447 }
291448 lines := []string{frameHeader(th, title, width)}
292- if it.desc != "" {
449+ // Keep room for the hint, closing rule, and at least the selected
450+ // option's label before spending a row on the optional description.
451+ if it.desc != "" && (d.MaxRows <= 0 || d.MaxRows >= 5) {
293452 lines = append(lines, th.FG256(th.Muted, it.desc))
294453 }
295454 lines = append(lines, th.FG256(th.Muted, "select with enter/space, esc to go back:"))
455+ blocks := make([][]string, len(it.options))
456+ heights := make([]int, len(it.options))
296457 for idx, opt := range it.options {
297458 marker := " "
298459 if idx == it.choice {
299460 marker = "✓ "
300461 }
301462 plain := " " + marker + opt.label
463+ block := make([]string, 0, 4)
302464 if idx == d.optionCursor {
303- lines = append(lines, th.PadHighlight(plain, width))
465+ block = append(block, th.PadHighlight(plain, width))
304466 } else {
305- lines = append(lines, plain)
467+ block = append(block, plain)
306468 }
307- if opt.desc != "" {
308- for _, desc := range wrapSettingDescription(opt.desc, width, 6) {
309- lines = append(lines, th.FG256(th.Muted, desc))
310- }
469+ for _, desc := range wrapSettingDescription(opt.desc, width, 6) {
470+ block = append(block, th.FG256(th.Muted, desc))
311471 }
472+ blocks[idx] = block
473+ heights[idx] = len(block)
312474 }
475+ // Same windowing as the item list: a theme list longer than the
476+ // terminal scrolls with the cursor instead of losing its head.
477+ budget := d.rowBudget(len(lines) + 1) // one row for the closing rule
478+ start, end := d.windowBlocks(heights, d.optionCursor, budget)
479+ lines = append(lines, windowLines(th, blocks, start, end, budget)...)
313480 lines = append(lines, frameRule(th, width))
314481 return lines
315482 }
@@ -1,6 +1,7 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "fmt"
4 "strings"5 "strings"
5 6
6 "github.com/mattn/go-runewidth"7 "github.com/mattn/go-runewidth"
@@ -18,6 +19,18 @@ type settingsDialog struct {
18 optionCursor int19 optionCursor int
19 parentItems []settingsItem20 parentItems []settingsItem
20 parentCursor int21 parentCursor int
22+
23+ // MaxRows caps how many rows the dialog renders. The host sets it
24+ // from the terminal height before each render; zero renders the
25+ // list in full. A list taller than the cap scrolls in a window
26+ // that follows the cursor so entries never disappear off-screen.
27+ MaxRows int
28+
29+ // viewTop is the index of the first entry drawn in the current
30+ // window. It survives across renders so the window only moves when
31+ // the cursor pushes past one of its edges, and is reset whenever
32+ // the dialog swaps between the item list and an option list.
33+ viewTop int
21 }34 }
22 35
23 type settingsItem struct {36 type settingsItem struct {
@@ -62,6 +75,7 @@ func (d *settingsDialog) Open(items []settingsItem) bool {
62 d.optionCursor = 075 d.optionCursor = 0
63 d.parentItems = nil76 d.parentItems = nil
64 d.parentCursor = 077 d.parentCursor = 0
78+ d.viewTop = 0
65 d.active = true79 d.active = true
66 return true80 return true
67 }81 }
@@ -81,6 +95,7 @@ func (d *settingsDialog) OpenDirectOption(item settingsItem) bool {
81 }95 }
82 d.parentItems = nil96 d.parentItems = nil
83 d.parentCursor = 097 d.parentCursor = 0
98+ d.viewTop = 0
84 d.active = true99 d.active = true
85 return true100 return true
86 }101 }
@@ -90,6 +105,7 @@ func (d *settingsDialog) Close() {
90 d.selecting = false105 d.selecting = false
91 d.direct = false106 d.direct = false
92 d.parentItems = nil107 d.parentItems = nil
108+ d.viewTop = 0
93 }109 }
94 func (d *settingsDialog) Active() bool { return d != nil && d.active }110 func (d *settingsDialog) Active() bool { return d != nil && d.active }
95 111
@@ -119,6 +135,7 @@ func (d *settingsDialog) HandleKey(k tui.Key) settingsAction {
119 d.cursor = d.parentCursor135 d.cursor = d.parentCursor
120 d.parentItems = nil136 d.parentItems = nil
121 d.parentCursor = 0137 d.parentCursor = 0
138+ d.viewTop = 0
122 d.title = "settings"139 d.title = "settings"
123 return settingsAction{}140 return settingsAction{}
124 }141 }
@@ -151,6 +168,7 @@ func (d *settingsDialog) handleOptionKey(k tui.Key) settingsAction {
151 return settingsAction{Close: true}168 return settingsAction{Close: true}
152 }169 }
153 d.selecting = false170 d.selecting = false
171+ d.viewTop = 0
154 case tui.KeyEnter:172 case tui.KeyEnter:
155 return d.selectCurrentOption()173 return d.selectCurrentOption()
156 case tui.KeyRune:174 case tui.KeyRune:
@@ -188,6 +206,7 @@ func (d *settingsDialog) toggleCurrent() settingsAction {
188 d.items = it.children206 d.items = it.children
189 d.cursor = 0207 d.cursor = 0
190 d.optionCursor = 0208 d.optionCursor = 0
209+ d.viewTop = 0
191 d.title = "settings: " + it.label210 d.title = "settings: " + it.label
192 return settingsAction{}211 return settingsAction{}
193 }212 }
@@ -197,6 +216,7 @@ func (d *settingsDialog) toggleCurrent() settingsAction {
197 d.optionCursor = 0216 d.optionCursor = 0
198 }217 }
199 d.selecting = true218 d.selecting = true
219+ d.viewTop = 0
200 return settingsAction{}220 return settingsAction{}
201 }221 }
202 it.value = !it.value222 it.value = !it.value
@@ -220,6 +240,7 @@ func (d *settingsDialog) selectCurrentOption() settingsAction {
220 it.choice = d.optionCursor240 it.choice = d.optionCursor
221 d.items[d.cursor] = it241 d.items[d.cursor] = it
222 d.selecting = false242 d.selecting = false
243+ d.viewTop = 0
223 action := settingsAction{Toggle: true, Key: it.key, StringValue: it.options[it.choice].value}244 action := settingsAction{Toggle: true, Key: it.key, StringValue: it.options[it.choice].value}
224 if d.direct {245 if d.direct {
225 d.Close()246 d.Close()
@@ -242,39 +263,175 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string {
242 } else {263 } else {
243 lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:"))264 lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:"))
244 }265 }
245- for i, it := range d.items {266+
246- box := "[ ]"267+ // Render every entry to its own block up front so the row window
247- if it.value {268+ // can count wrapped descriptions, then draw only the blocks that
248- box = "[✓]"269+ // fit the budget. Windowing keeps the cursor entry on screen: a
249- }270+ // list taller than the terminal scrolls instead of having its top
250- plain := " " + box + " " + it.label271+ // entries clipped off and unreachable.
251- if it.picker || len(it.children) > 0 {272+ blocks := make([][]string, len(d.items))
252- box = "[→]"273+ heights := make([]int, len(d.items))
253- plain = " " + box + " " + it.label274+ for i := range d.items {
254- } else if len(it.options) > 0 {275+ blocks[i] = d.itemBlock(th, width, i)
255- box = "[→]"276+ heights[i] = len(blocks[i])
256- if it.choice < 0 || it.choice >= len(it.options) {277+ }
257- it.choice = 0278+ budget := d.rowBudget(len(lines) + 1) // one row for the closing rule
258- }279+ start, end := d.windowBlocks(heights, d.cursor, budget)
259- plain = " " + box + " " + it.label + ": " + it.options[it.choice].label280+ lines = append(lines, windowLines(th, blocks, start, end, budget)...)
260- }281+ lines = append(lines, frameRule(th, width))
261- if it.hint != "" {282+ return lines
262- plain += " " + th.FG256(th.Muted, "("+it.hint+")")283+}
284+
285+// itemBlock renders one entry as its label row followed by any wrapped
286+// description rows. Keeping both in one block lets the row window
287+// count the description and trim it when space runs out without losing
288+// the label row the cursor highlight sits on.
289+func (d *settingsDialog) itemBlock(th tui.Theme, width, idx int) []string {
290+ it := d.items[idx]
291+ box := "[ ]"
292+ if it.value {
293+ box = "[✓]"
294+ }
295+ plain := " " + box + " " + it.label
296+ if it.picker || len(it.children) > 0 {
297+ box = "[→]"
298+ plain = " " + box + " " + it.label
299+ } else if len(it.options) > 0 {
300+ box = "[→]"
301+ if it.choice < 0 || it.choice >= len(it.options) {
302+ it.choice = 0
263 }303 }
264- if it.disabled {304+ plain = " " + box + " " + it.label + ": " + it.options[it.choice].label
265- lines = append(lines, th.FG256(th.Muted, plain))305+ }
266- } else if i == d.cursor {306+ if it.hint != "" {
267- lines = append(lines, th.PadHighlight(plain, width))307+ plain += " " + th.FG256(th.Muted, "("+it.hint+")")
268- } else {308+ }
269- lines = append(lines, plain)309+ line := plain
310+ switch {
311+ case it.disabled:
312+ line = th.FG256(th.Muted, plain)
313+ case idx == d.cursor:
314+ line = th.PadHighlight(plain, width)
315+ }
316+ block := []string{line}
317+ for _, desc := range wrapSettingDescription(it.desc, width, 6) {
318+ block = append(block, th.FG256(th.Muted, desc))
319+ }
320+ return block
321+}
322+
323+// rowBudget returns the rows left for the window after the fixed rows
324+// around it (frame header, hint, closing rule) are counted against
325+// MaxRows. Zero means unbounded.
326+func (d *settingsDialog) rowBudget(fixed int) int {
327+ if d.MaxRows <= 0 {
328+ return 0
329+ }
330+ if budget := d.MaxRows - fixed; budget > 0 {
331+ return budget
332+ }
333+ return 1
334+}
335+
336+// windowBlocks returns the [start, end) range of entry blocks to draw
337+// so the cursor entry stays visible. Heights are the rendered row
338+// counts per entry, budget is the rows available for the blocks plus
339+// the "more above/below" markers, and zero means windowing is off.
340+func (d *settingsDialog) windowBlocks(heights []int, cursor, budget int) (start, end int) {
341+ total := len(heights)
342+ if total == 0 {
343+ return 0, 0
344+ }
345+ if budget <= 0 {
346+ d.viewTop = 0
347+ return 0, total
348+ }
349+ if cursor < 0 {
350+ cursor = 0
351+ }
352+ if cursor >= total {
353+ cursor = total - 1
354+ }
355+ rows := 0
356+ for _, h := range heights {
357+ rows += h
358+ }
359+ if rows <= budget {
360+ d.viewTop = 0
361+ return 0, total
362+ }
363+ // Entries are hidden above and/or below, so hold back a row for
364+ // the markers. On a terminal too short for the cursor entry plus
365+ // markers, drop the reservation: the highlighted row matters more
366+ // than the hint that more entries exist.
367+ inner := budget - 2
368+ if inner < heights[cursor] {
369+ inner = budget
370+ }
371+ return d.anchorBlocks(heights, cursor, inner)
372+}
373+
374+// anchorBlocks returns the block range of combined height <= budget
375+// that keeps the cursor entry visible. The window extends downward
376+// from the persistent viewTop; when the cursor moves past either edge
377+// the window re-anchors so the cursor entry becomes the first or last
378+// visible one. viewTop is updated for the next render so the window
379+// stays put while the cursor moves inside it.
380+func (d *settingsDialog) anchorBlocks(heights []int, cursor, budget int) (start, end int) {
381+ total := len(heights)
382+ start = d.viewTop
383+ if start > cursor || start >= total {
384+ start = cursor
385+ }
386+ used := 0
387+ for end = start; end < total && (end == start || used+heights[end] <= budget); end++ {
388+ used += heights[end]
389+ }
390+ if cursor >= end {
391+ // The cursor fell past the bottom edge: re-anchor on it and
392+ // fill the remaining budget with entries above.
393+ start, end = cursor, cursor+1
394+ used = heights[cursor]
395+ for start > 0 && used+heights[start-1] <= budget {
396+ start--
397+ used += heights[start]
270 }398 }
271- if it.desc != "" {399+ }
272- for _, desc := range wrapSettingDescription(it.desc, width, 6) {400+ d.viewTop = start
273- lines = append(lines, th.FG256(th.Muted, desc))401+ return start, end
402+}
403+
404+// windowLines renders the [start, end) slice of entry blocks, framed by
405+// muted "more above/below" markers when entries are hidden on either
406+// side. Blocks are trimmed so the result never uses more than budget
407+// rows; budget <= 0 means unbounded.
408+func windowLines(th tui.Theme, blocks [][]string, start, end, budget int) []string {
409+ unbounded := budget <= 0
410+ remaining := budget
411+ var lines []string
412+ // Spend a row on the above marker only when the first block still
413+ // gets a row afterwards: on a terminal this short the highlight
414+ // matters more than the hint that entries are hidden.
415+ if start > 0 && (unbounded || remaining > 1) {
416+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", start)))
417+ remaining--
418+ }
419+ for i := start; i < end; i++ {
420+ block := blocks[i]
421+ if !unbounded {
422+ if remaining <= 0 {
423+ break
424+ }
425+ if len(block) > remaining {
426+ block = block[:remaining]
274 }427 }
275 }428 }
429+ lines = append(lines, block...)
430+ remaining -= len(block)
431+ }
432+ if end < len(blocks) && (unbounded || remaining > 0) {
433+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", len(blocks)-end)))
276 }434 }
277- lines = append(lines, frameRule(th, width))
278 return lines435 return lines
279 }436 }
280 437
@@ -289,27 +446,37 @@ func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string {
289 title = d.title446 title = d.title
290 }447 }
291 lines := []string{frameHeader(th, title, width)}448 lines := []string{frameHeader(th, title, width)}
292- if it.desc != "" {449+ // Keep room for the hint, closing rule, and at least the selected
450+ // option's label before spending a row on the optional description.
451+ if it.desc != "" && (d.MaxRows <= 0 || d.MaxRows >= 5) {
293 lines = append(lines, th.FG256(th.Muted, it.desc))452 lines = append(lines, th.FG256(th.Muted, it.desc))
294 }453 }
295 lines = append(lines, th.FG256(th.Muted, "select with enter/space, esc to go back:"))454 lines = append(lines, th.FG256(th.Muted, "select with enter/space, esc to go back:"))
455+ blocks := make([][]string, len(it.options))
456+ heights := make([]int, len(it.options))
296 for idx, opt := range it.options {457 for idx, opt := range it.options {
297 marker := " "458 marker := " "
298 if idx == it.choice {459 if idx == it.choice {
299 marker = "✓ "460 marker = "✓ "
300 }461 }
301 plain := " " + marker + opt.label462 plain := " " + marker + opt.label
463+ block := make([]string, 0, 4)
302 if idx == d.optionCursor {464 if idx == d.optionCursor {
303- lines = append(lines, th.PadHighlight(plain, width))465+ block = append(block, th.PadHighlight(plain, width))
304 } else {466 } else {
305- lines = append(lines, plain)467+ block = append(block, plain)
306 }468 }
307- if opt.desc != "" {469+ for _, desc := range wrapSettingDescription(opt.desc, width, 6) {
308- for _, desc := range wrapSettingDescription(opt.desc, width, 6) {470+ block = append(block, th.FG256(th.Muted, desc))
309- lines = append(lines, th.FG256(th.Muted, desc))
310- }
311 }471 }
472+ blocks[idx] = block
473+ heights[idx] = len(block)
312 }474 }
475+ // Same windowing as the item list: a theme list longer than the
476+ // terminal scrolls with the cursor instead of losing its head.
477+ budget := d.rowBudget(len(lines) + 1) // one row for the closing rule
478+ start, end := d.windowBlocks(heights, d.optionCursor, budget)
479+ lines = append(lines, windowLines(th, blocks, start, end, budget)...)
313 lines = append(lines, frameRule(th, width))480 lines = append(lines, frameRule(th, width))
314 return lines481 return lines
315 }482 }
A packages/agent/modes/settings_dialog_test.go +231 -0
@@ -0,0 +1,231 @@
1+package modes
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "io"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/tui"
12+)
13+
14+// settingsTestItems builds a list long enough to overflow a short
15+// terminal. Every entry carries a description that wraps, so an entry
16+// costs more than one row and the window has to account for it.
17+func settingsTestItems(n int) []settingsItem {
18+ items := make([]settingsItem, 0, n)
19+ for i := 0; i < n; i++ {
20+ items = append(items, settingsItem{
21+ key: fmt.Sprintf("item_%02d", i),
22+ label: fmt.Sprintf("entry %02d", i),
23+ desc: "description text that wraps onto several rows at the widths these tests render so every entry costs more than a single line",
24+ })
25+ }
26+ return items
27+}
28+
29+func renderedPlain(lines []string) string { return stripANSIBytes(strings.Join(lines, "\n")) }
30+
31+func TestSettingsDialogWindowsListToMaxRows(t *testing.T) {
32+ d := newSettingsDialog()
33+ d.Open(settingsTestItems(20))
34+ d.MaxRows = 12
35+
36+ lines := d.Render(tui.Dark, 80)
37+ if len(lines) > d.MaxRows {
38+ t.Fatalf("rendered %d rows, want <= %d", len(lines), d.MaxRows)
39+ }
40+ text := renderedPlain(lines)
41+ for _, want := range []string{"entry 00", "entry 01", "more below"} {
42+ if !strings.Contains(text, want) {
43+ t.Fatalf("top of window missing %q:\n%s", want, text)
44+ }
45+ }
46+ if strings.Contains(text, "entry 19") {
47+ t.Fatalf("window rendered the far tail of the list:\n%s", text)
48+ }
49+}
50+
51+func TestSettingsDialogWindowFollowsCursorToBothEnds(t *testing.T) {
52+ d := newSettingsDialog()
53+ d.Open(settingsTestItems(20))
54+ d.MaxRows = 10
55+
56+ for i := 1; i < 20; i++ {
57+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
58+ lines := d.Render(tui.Dark, 80)
59+ if len(lines) > d.MaxRows {
60+ t.Fatalf("cursor at %d: rendered %d rows, want <= %d", i, len(lines), d.MaxRows)
61+ }
62+ if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("entry %02d", i)) {
63+ t.Fatalf("cursor entry %02d scrolled out of view:\n%s", i, text)
64+ }
65+ }
66+ top := d.Render(tui.Dark, 80)
67+ if text := renderedPlain(top); !strings.Contains(text, "more above") {
68+ t.Fatalf("bottom of list missing the above marker:\n%s", text)
69+ }
70+
71+ for i := 18; i >= 0; i-- {
72+ d.HandleKey(tui.Key{Kind: tui.KeyUp})
73+ lines := d.Render(tui.Dark, 80)
74+ if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("entry %02d", i)) {
75+ t.Fatalf("cursor entry %02d scrolled out of view on the way back:\n%s", i, text)
76+ }
77+ }
78+ if text := renderedPlain(d.Render(tui.Dark, 80)); !strings.Contains(text, "entry 00") {
79+ t.Fatalf("first entry not visible after scrolling back up:\n%s", text)
80+ }
81+}
82+
83+func TestSettingsDialogOptionsWindowFollowsCursor(t *testing.T) {
84+ item := settingsItem{key: "theme", label: "color theme"}
85+ for i := 0; i < 30; i++ {
86+ item.options = append(item.options, settingsOption{
87+ value: fmt.Sprintf("opt_%02d", i),
88+ label: fmt.Sprintf("option %02d", i),
89+ desc: "option description that also wraps onto several rows at the widths these tests render",
90+ })
91+ }
92+ d := newSettingsDialog()
93+ d.OpenDirectOption(item)
94+ d.MaxRows = 14
95+
96+ for i := 1; i < 30; i++ {
97+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
98+ lines := d.Render(tui.Dark, 80)
99+ if len(lines) > d.MaxRows {
100+ t.Fatalf("option cursor at %d: rendered %d rows, want <= %d", i, len(lines), d.MaxRows)
101+ }
102+ if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("option %02d", i)) {
103+ t.Fatalf("option %02d scrolled out of view:\n%s", i, text)
104+ }
105+ }
106+ last := d.Render(tui.Dark, 80)
107+ if text := renderedPlain(last); !strings.Contains(text, "more above") || !strings.Contains(text, "option 29") {
108+ t.Fatalf("bottom of option list not windowed on the cursor:\n%s", text)
109+ }
110+}
111+
112+func TestSettingsDialogKeepsCursorRowOnTinyTerminal(t *testing.T) {
113+ d := newSettingsDialog()
114+ d.Open(settingsTestItems(20))
115+ // Four rows is the dialog's floor: header, hint, one row of list,
116+ // rule. The cursor entry must still be the row that survives.
117+ d.MaxRows = 4
118+ for i := 0; i < 20; i++ {
119+ lines := d.Render(tui.Dark, 80)
120+ if len(lines) > d.MaxRows {
121+ t.Fatalf("cursor at %d: rendered %d rows, want <= %d", i, len(lines), d.MaxRows)
122+ }
123+ if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("entry %02d", i)) {
124+ t.Fatalf("cursor entry %02d missing at the row floor:\n%s", i, text)
125+ }
126+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
127+ }
128+}
129+
130+func TestSettingsOptionPickerFitsShortTerminal(t *testing.T) {
131+ for _, rows := range []int{12, 14, 16, 17, 24} {
132+ t.Run(fmt.Sprintf("rows=%d", rows), func(t *testing.T) {
133+ term := &shortTestTerminal{cols: 80, rows: rows}
134+ i := NewInteractive(InteractiveConfig{Terminal: term})
135+ i.settingsDialog.OpenDirectOption(settingsItem{
136+ label: "reasoning level", desc: "reasoning depth for reasoning-capable models",
137+ options: []settingsOption{{label: "off"}, {label: "low"}},
138+ })
139+ i.redraw()
140+
141+ lines := i.settingsDialog.Render(i.cfg.Theme, 80)
142+ if len(lines) > i.settingsDialog.MaxRows {
143+ t.Fatalf("option picker rendered %d rows, cap %d", len(lines), i.settingsDialog.MaxRows)
144+ }
145+ text := renderedPlain(lines)
146+ if !strings.Contains(text, "off") {
147+ t.Fatalf("selected option missing from window:\n%s", text)
148+ }
149+ if i.settingsDialog.MaxRows >= 5 && !strings.Contains(text, "reasoning depth") {
150+ t.Fatalf("description missing when it fits:\n%s", text)
151+ }
152+ written := strings.Count(term.String(), "\r\n") + 1
153+ band := written - len(i.cachedChatLocked(80))
154+ if band > rows-1 {
155+ t.Fatalf("bottom band is %d rows for a %d-row terminal", band, rows)
156+ }
157+ })
158+ }
159+}
160+
161+func TestSettingsDialogRendersEverythingWithoutMaxRows(t *testing.T) {
162+ d := newSettingsDialog()
163+ d.Open(settingsTestItems(20))
164+
165+ text := renderedPlain(d.Render(tui.Dark, 80))
166+ for _, want := range []string{"entry 00", "entry 19"} {
167+ if !strings.Contains(text, want) {
168+ t.Fatalf("unbounded render missing %q:\n%s", want, text)
169+ }
170+ }
171+ if strings.Contains(text, "more above") || strings.Contains(text, "more below") {
172+ t.Fatalf("unbounded render drew window markers:\n%s", text)
173+ }
174+}
175+
176+// shortTestTerminal is a Terminal with a configurable size so the
177+// layout can be exercised at heights a real window would report.
178+type shortTestTerminal struct {
179+ bytes.Buffer
180+ cols, rows int
181+}
182+
183+func (t *shortTestTerminal) Size() (int, int) { return t.cols, t.rows }
184+func (t *shortTestTerminal) OnResize(func()) {}
185+func (t *shortTestTerminal) SetNonblock(bool) error { return nil }
186+func (t *shortTestTerminal) ReadByte() (byte, error) {
187+ return 0, io.EOF
188+}
189+func (t *shortTestTerminal) EnterRaw() (func() error, error) {
190+ return func() error { return nil }, nil
191+}
192+func (t *shortTestTerminal) PeekByteTimeout(time.Duration) (byte, bool, error) {
193+ return 0, false, io.EOF
194+}
195+
196+func TestSettingsDialogIsCappedToShortTerminal(t *testing.T) {
197+ for _, rows := range []int{12, 14, 16, 24, 40, 60} {
198+ t.Run(fmt.Sprintf("rows=%d", rows), func(t *testing.T) {
199+ term := &shortTestTerminal{cols: 80, rows: rows}
200+ i := NewInteractive(InteractiveConfig{Terminal: term})
201+ i.openSettingsDialog()
202+ if !i.settingsDialog.Active() {
203+ t.Fatal("settings dialog did not open")
204+ }
205+ i.redraw()
206+
207+ if i.settingsDialog.MaxRows <= 0 || i.settingsDialog.MaxRows >= term.rows {
208+ t.Fatalf("dialog cap = %d rows for a %d-row terminal", i.settingsDialog.MaxRows, term.rows)
209+ }
210+ lines := i.settingsDialog.Render(i.cfg.Theme, 80)
211+ if len(lines) > i.settingsDialog.MaxRows {
212+ t.Fatalf("dialog rendered %d rows, cap %d", len(lines), i.settingsDialog.MaxRows)
213+ }
214+ text := renderedPlain(lines)
215+ if !strings.Contains(text, "render images when supported") {
216+ t.Fatalf("first settings entry missing from the window:\n%s", text)
217+ }
218+
219+ // The band the dialog draws into must fit above the
220+ // terminal's bottom edge: written lines are the chat, the
221+ // bottom band, and the renderer's trailing margin row. A
222+ // taller band scrolls the top of the dialog out of reach
223+ // again.
224+ written := strings.Count(term.String(), "\r\n") + 1
225+ band := written - len(i.cachedChatLocked(80))
226+ if band > term.rows-1 {
227+ t.Fatalf("bottom band is %d rows for a %d-row terminal; dialog would be clipped", band, term.rows)
228+ }
229+ })
230+ }
231+}
@@ -0,0 +1,231 @@
1+package modes
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "io"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/tui"
12+)
13+
14+// settingsTestItems builds a list long enough to overflow a short
15+// terminal. Every entry carries a description that wraps, so an entry
16+// costs more than one row and the window has to account for it.
17+func settingsTestItems(n int) []settingsItem {
18+ items := make([]settingsItem, 0, n)
19+ for i := 0; i < n; i++ {
20+ items = append(items, settingsItem{
21+ key: fmt.Sprintf("item_%02d", i),
22+ label: fmt.Sprintf("entry %02d", i),
23+ desc: "description text that wraps onto several rows at the widths these tests render so every entry costs more than a single line",
24+ })
25+ }
26+ return items
27+}
28+
29+func renderedPlain(lines []string) string { return stripANSIBytes(strings.Join(lines, "\n")) }
30+
31+func TestSettingsDialogWindowsListToMaxRows(t *testing.T) {
32+ d := newSettingsDialog()
33+ d.Open(settingsTestItems(20))
34+ d.MaxRows = 12
35+
36+ lines := d.Render(tui.Dark, 80)
37+ if len(lines) > d.MaxRows {
38+ t.Fatalf("rendered %d rows, want <= %d", len(lines), d.MaxRows)
39+ }
40+ text := renderedPlain(lines)
41+ for _, want := range []string{"entry 00", "entry 01", "more below"} {
42+ if !strings.Contains(text, want) {
43+ t.Fatalf("top of window missing %q:\n%s", want, text)
44+ }
45+ }
46+ if strings.Contains(text, "entry 19") {
47+ t.Fatalf("window rendered the far tail of the list:\n%s", text)
48+ }
49+}
50+
51+func TestSettingsDialogWindowFollowsCursorToBothEnds(t *testing.T) {
52+ d := newSettingsDialog()
53+ d.Open(settingsTestItems(20))
54+ d.MaxRows = 10
55+
56+ for i := 1; i < 20; i++ {
57+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
58+ lines := d.Render(tui.Dark, 80)
59+ if len(lines) > d.MaxRows {
60+ t.Fatalf("cursor at %d: rendered %d rows, want <= %d", i, len(lines), d.MaxRows)
61+ }
62+ if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("entry %02d", i)) {
63+ t.Fatalf("cursor entry %02d scrolled out of view:\n%s", i, text)
64+ }
65+ }
66+ top := d.Render(tui.Dark, 80)
67+ if text := renderedPlain(top); !strings.Contains(text, "more above") {
68+ t.Fatalf("bottom of list missing the above marker:\n%s", text)
69+ }
70+
71+ for i := 18; i >= 0; i-- {
72+ d.HandleKey(tui.Key{Kind: tui.KeyUp})
73+ lines := d.Render(tui.Dark, 80)
74+ if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("entry %02d", i)) {
75+ t.Fatalf("cursor entry %02d scrolled out of view on the way back:\n%s", i, text)
76+ }
77+ }
78+ if text := renderedPlain(d.Render(tui.Dark, 80)); !strings.Contains(text, "entry 00") {
79+ t.Fatalf("first entry not visible after scrolling back up:\n%s", text)
80+ }
81+}
82+
83+func TestSettingsDialogOptionsWindowFollowsCursor(t *testing.T) {
84+ item := settingsItem{key: "theme", label: "color theme"}
85+ for i := 0; i < 30; i++ {
86+ item.options = append(item.options, settingsOption{
87+ value: fmt.Sprintf("opt_%02d", i),
88+ label: fmt.Sprintf("option %02d", i),
89+ desc: "option description that also wraps onto several rows at the widths these tests render",
90+ })
91+ }
92+ d := newSettingsDialog()
93+ d.OpenDirectOption(item)
94+ d.MaxRows = 14
95+
96+ for i := 1; i < 30; i++ {
97+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
98+ lines := d.Render(tui.Dark, 80)
99+ if len(lines) > d.MaxRows {
100+ t.Fatalf("option cursor at %d: rendered %d rows, want <= %d", i, len(lines), d.MaxRows)
101+ }
102+ if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("option %02d", i)) {
103+ t.Fatalf("option %02d scrolled out of view:\n%s", i, text)
104+ }
105+ }
106+ last := d.Render(tui.Dark, 80)
107+ if text := renderedPlain(last); !strings.Contains(text, "more above") || !strings.Contains(text, "option 29") {
108+ t.Fatalf("bottom of option list not windowed on the cursor:\n%s", text)
109+ }
110+}
111+
112+func TestSettingsDialogKeepsCursorRowOnTinyTerminal(t *testing.T) {
113+ d := newSettingsDialog()
114+ d.Open(settingsTestItems(20))
115+ // Four rows is the dialog's floor: header, hint, one row of list,
116+ // rule. The cursor entry must still be the row that survives.
117+ d.MaxRows = 4
118+ for i := 0; i < 20; i++ {
119+ lines := d.Render(tui.Dark, 80)
120+ if len(lines) > d.MaxRows {
121+ t.Fatalf("cursor at %d: rendered %d rows, want <= %d", i, len(lines), d.MaxRows)
122+ }
123+ if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("entry %02d", i)) {
124+ t.Fatalf("cursor entry %02d missing at the row floor:\n%s", i, text)
125+ }
126+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
127+ }
128+}
129+
130+func TestSettingsOptionPickerFitsShortTerminal(t *testing.T) {
131+ for _, rows := range []int{12, 14, 16, 17, 24} {
132+ t.Run(fmt.Sprintf("rows=%d", rows), func(t *testing.T) {
133+ term := &shortTestTerminal{cols: 80, rows: rows}
134+ i := NewInteractive(InteractiveConfig{Terminal: term})
135+ i.settingsDialog.OpenDirectOption(settingsItem{
136+ label: "reasoning level", desc: "reasoning depth for reasoning-capable models",
137+ options: []settingsOption{{label: "off"}, {label: "low"}},
138+ })
139+ i.redraw()
140+
141+ lines := i.settingsDialog.Render(i.cfg.Theme, 80)
142+ if len(lines) > i.settingsDialog.MaxRows {
143+ t.Fatalf("option picker rendered %d rows, cap %d", len(lines), i.settingsDialog.MaxRows)
144+ }
145+ text := renderedPlain(lines)
146+ if !strings.Contains(text, "off") {
147+ t.Fatalf("selected option missing from window:\n%s", text)
148+ }
149+ if i.settingsDialog.MaxRows >= 5 && !strings.Contains(text, "reasoning depth") {
150+ t.Fatalf("description missing when it fits:\n%s", text)
151+ }
152+ written := strings.Count(term.String(), "\r\n") + 1
153+ band := written - len(i.cachedChatLocked(80))
154+ if band > rows-1 {
155+ t.Fatalf("bottom band is %d rows for a %d-row terminal", band, rows)
156+ }
157+ })
158+ }
159+}
160+
161+func TestSettingsDialogRendersEverythingWithoutMaxRows(t *testing.T) {
162+ d := newSettingsDialog()
163+ d.Open(settingsTestItems(20))
164+
165+ text := renderedPlain(d.Render(tui.Dark, 80))
166+ for _, want := range []string{"entry 00", "entry 19"} {
167+ if !strings.Contains(text, want) {
168+ t.Fatalf("unbounded render missing %q:\n%s", want, text)
169+ }
170+ }
171+ if strings.Contains(text, "more above") || strings.Contains(text, "more below") {
172+ t.Fatalf("unbounded render drew window markers:\n%s", text)
173+ }
174+}
175+
176+// shortTestTerminal is a Terminal with a configurable size so the
177+// layout can be exercised at heights a real window would report.
178+type shortTestTerminal struct {
179+ bytes.Buffer
180+ cols, rows int
181+}
182+
183+func (t *shortTestTerminal) Size() (int, int) { return t.cols, t.rows }
184+func (t *shortTestTerminal) OnResize(func()) {}
185+func (t *shortTestTerminal) SetNonblock(bool) error { return nil }
186+func (t *shortTestTerminal) ReadByte() (byte, error) {
187+ return 0, io.EOF
188+}
189+func (t *shortTestTerminal) EnterRaw() (func() error, error) {
190+ return func() error { return nil }, nil
191+}
192+func (t *shortTestTerminal) PeekByteTimeout(time.Duration) (byte, bool, error) {
193+ return 0, false, io.EOF
194+}
195+
196+func TestSettingsDialogIsCappedToShortTerminal(t *testing.T) {
197+ for _, rows := range []int{12, 14, 16, 24, 40, 60} {
198+ t.Run(fmt.Sprintf("rows=%d", rows), func(t *testing.T) {
199+ term := &shortTestTerminal{cols: 80, rows: rows}
200+ i := NewInteractive(InteractiveConfig{Terminal: term})
201+ i.openSettingsDialog()
202+ if !i.settingsDialog.Active() {
203+ t.Fatal("settings dialog did not open")
204+ }
205+ i.redraw()
206+
207+ if i.settingsDialog.MaxRows <= 0 || i.settingsDialog.MaxRows >= term.rows {
208+ t.Fatalf("dialog cap = %d rows for a %d-row terminal", i.settingsDialog.MaxRows, term.rows)
209+ }
210+ lines := i.settingsDialog.Render(i.cfg.Theme, 80)
211+ if len(lines) > i.settingsDialog.MaxRows {
212+ t.Fatalf("dialog rendered %d rows, cap %d", len(lines), i.settingsDialog.MaxRows)
213+ }
214+ text := renderedPlain(lines)
215+ if !strings.Contains(text, "render images when supported") {
216+ t.Fatalf("first settings entry missing from the window:\n%s", text)
217+ }
218+
219+ // The band the dialog draws into must fit above the
220+ // terminal's bottom edge: written lines are the chat, the
221+ // bottom band, and the renderer's trailing margin row. A
222+ // taller band scrolls the top of the dialog out of reach
223+ // again.
224+ written := strings.Count(term.String(), "\r\n") + 1
225+ band := written - len(i.cachedChatLocked(80))
226+ if band > term.rows-1 {
227+ t.Fatalf("bottom band is %d rows for a %d-row terminal; dialog would be clipped", band, term.rows)
228+ }
229+ })
230+ }
231+}
M packages/provider/amazon_bedrock.go +97 -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,117 @@ 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.
148+ return "", resolveBedrockDiscoveryCreds()
149+}
150+
151+// resolveBedrockDiscoveryCreds ignores inference bearer tokens: Bedrock's
152+// control-plane listing endpoints require SigV4 even when inference uses a bearer.
153+func resolveBedrockDiscoveryCreds() *bedrockSigV4Creds {
116154 ak := os.Getenv("AWS_ACCESS_KEY_ID")
117155 sk := os.Getenv("AWS_SECRET_ACCESS_KEY")
118156 st := os.Getenv("AWS_SESSION_TOKEN")
119157 if ak != "" && sk != "" {
120- c.sigv4 = &bedrockSigV4Creds{accessKeyID: ak, secretAccessKey: sk, sessionToken: st}
121- return c
158+ return &bedrockSigV4Creds{accessKeyID: ak, secretAccessKey: sk, sessionToken: st}
122159 }
123160
124- // SigV4 route: ~/.aws/credentials via AWS_PROFILE.
125161 if profile := os.Getenv("AWS_PROFILE"); profile != "" {
126162 if creds, err := readAWSCredentialsFile(profile); err == nil {
127- c.sigv4 = creds
128- return c
163+ return creds
129164 }
130165 }
131166
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)",
167+ // Last resort: ask the AWS CLI to resolve credentials for the active
168+ // profile. This handles SSO, assumed roles, credential_process, and
169+ // web-identity setups whose credentials are not stored as static keys
170+ // in ~/.aws/credentials. Best-effort: absent CLI or an expired login
171+ // simply yields no credentials.
172+ if creds := resolveBedrockCredsViaCLI(); creds != nil {
173+ return creds
174+ }
175+
176+ return nil
177+}
178+
179+// resolveBedrockCredsViaCLI shells out to `aws configure
180+// export-credentials --format process`, which returns fully-resolved
181+// credentials for the active profile regardless of how that profile is
182+// configured (static keys, SSO, assume-role, credential_process). The
183+// AWS CLI must be installed and the profile's login (if any) still
184+// valid. Returns nil on any failure so the caller can fall through.
185+func resolveBedrockCredsViaCLI() *bedrockSigV4Creds {
186+ if _, err := exec.LookPath("aws"); err != nil {
187+ return nil
188+ }
189+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
190+ defer cancel()
191+ // AWS_PROFILE / AWS_REGION already in the environment are inherited,
192+ // so the CLI resolves the same profile zot was pointed at.
193+ cmd := exec.CommandContext(ctx, "aws", "configure", "export-credentials", "--format", "process")
194+ out, err := cmd.Output()
195+ if err != nil {
196+ return nil
197+ }
198+ var exported struct {
199+ AccessKeyID string `json:"AccessKeyId"`
200+ SecretAccessKey string `json:"SecretAccessKey"`
201+ SessionToken string `json:"SessionToken"`
202+ }
203+ if err := json.Unmarshal(out, &exported); err != nil {
204+ return nil
205+ }
206+ if exported.AccessKeyID == "" || exported.SecretAccessKey == "" {
207+ return nil
208+ }
209+ return &bedrockSigV4Creds{
210+ accessKeyID: exported.AccessKeyID,
211+ secretAccessKey: exported.SecretAccessKey,
212+ sessionToken: exported.SessionToken,
135213 }
136214 }
137215
@@ -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,117 @@ 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.148+ return "", resolveBedrockDiscoveryCreds()
149+}
150+
151+// resolveBedrockDiscoveryCreds ignores inference bearer tokens: Bedrock's
152+// control-plane listing endpoints require SigV4 even when inference uses a bearer.
153+func resolveBedrockDiscoveryCreds() *bedrockSigV4Creds {
116 ak := os.Getenv("AWS_ACCESS_KEY_ID")154 ak := os.Getenv("AWS_ACCESS_KEY_ID")
117 sk := os.Getenv("AWS_SECRET_ACCESS_KEY")155 sk := os.Getenv("AWS_SECRET_ACCESS_KEY")
118 st := os.Getenv("AWS_SESSION_TOKEN")156 st := os.Getenv("AWS_SESSION_TOKEN")
119 if ak != "" && sk != "" {157 if ak != "" && sk != "" {
120- c.sigv4 = &bedrockSigV4Creds{accessKeyID: ak, secretAccessKey: sk, sessionToken: st}158+ return &bedrockSigV4Creds{accessKeyID: ak, secretAccessKey: sk, sessionToken: st}
121- return c
122 }159 }
123 160
124- // SigV4 route: ~/.aws/credentials via AWS_PROFILE.
125 if profile := os.Getenv("AWS_PROFILE"); profile != "" {161 if profile := os.Getenv("AWS_PROFILE"); profile != "" {
126 if creds, err := readAWSCredentialsFile(profile); err == nil {162 if creds, err := readAWSCredentialsFile(profile); err == nil {
127- c.sigv4 = creds163+ return creds
128- return c
129 }164 }
130 }165 }
131 166
132- return &unimplementedClient{167+ // Last resort: ask the AWS CLI to resolve credentials for the active
133- name: "amazon-bedrock",168+ // 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)",169+ // web-identity setups whose credentials are not stored as static keys
170+ // in ~/.aws/credentials. Best-effort: absent CLI or an expired login
171+ // simply yields no credentials.
172+ if creds := resolveBedrockCredsViaCLI(); creds != nil {
173+ return creds
174+ }
175+
176+ return nil
177+}
178+
179+// resolveBedrockCredsViaCLI shells out to `aws configure
180+// export-credentials --format process`, which returns fully-resolved
181+// credentials for the active profile regardless of how that profile is
182+// configured (static keys, SSO, assume-role, credential_process). The
183+// AWS CLI must be installed and the profile's login (if any) still
184+// valid. Returns nil on any failure so the caller can fall through.
185+func resolveBedrockCredsViaCLI() *bedrockSigV4Creds {
186+ if _, err := exec.LookPath("aws"); err != nil {
187+ return nil
188+ }
189+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
190+ defer cancel()
191+ // AWS_PROFILE / AWS_REGION already in the environment are inherited,
192+ // so the CLI resolves the same profile zot was pointed at.
193+ cmd := exec.CommandContext(ctx, "aws", "configure", "export-credentials", "--format", "process")
194+ out, err := cmd.Output()
195+ if err != nil {
196+ return nil
197+ }
198+ var exported struct {
199+ AccessKeyID string `json:"AccessKeyId"`
200+ SecretAccessKey string `json:"SecretAccessKey"`
201+ SessionToken string `json:"SessionToken"`
202+ }
203+ if err := json.Unmarshal(out, &exported); err != nil {
204+ return nil
205+ }
206+ if exported.AccessKeyID == "" || exported.SecretAccessKey == "" {
207+ return nil
208+ }
209+ return &bedrockSigV4Creds{
210+ accessKeyID: exported.AccessKeyID,
211+ secretAccessKey: exported.SecretAccessKey,
212+ sessionToken: exported.SessionToken,
135 }213 }
136 }214 }
137 215
M packages/provider/catalog_builtin.go +9 -3
@@ -34,14 +34,16 @@ var builtinCatalog = []Model{
3434 {Provider: "amazon-bedrock", ID: "anthropic.claude-opus-4-6-v1", DisplayName: "Claude Opus 4.6", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
3535 {Provider: "amazon-bedrock", ID: "anthropic.claude-opus-4-7", DisplayName: "Claude Opus 4.7", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
3636 {Provider: "amazon-bedrock", ID: "anthropic.claude-opus-4-8", DisplayName: "Claude Opus 4.8", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
37+ {Provider: "amazon-bedrock", ID: "anthropic.claude-opus-5-5", DisplayName: "Claude Opus 5.5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 4, PriceOutput: 20, PriceCacheRead: 0.2, PriceCacheWrite: 5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
3738 {Provider: "amazon-bedrock", ID: "anthropic.claude-sonnet-4-5-20250929-v1:0", DisplayName: "Claude Sonnet 4.5", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
3839 {Provider: "amazon-bedrock", ID: "anthropic.claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
3940 {Provider: "amazon-bedrock", ID: "anthropic.claude-sonnet-5", DisplayName: "Claude Sonnet 5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
4041 {Provider: "amazon-bedrock", ID: "au.anthropic.claude-haiku-4-5-20251001-v1:0", DisplayName: "Claude Haiku 4.5 (AU)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 1, PriceOutput: 5, PriceCacheRead: 0.1, PriceCacheWrite: 1.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
41- {Provider: "amazon-bedrock", ID: "au.anthropic.claude-opus-4-6-v1", DisplayName: "AU Anthropic Claude Opus 4.6", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 16.5, PriceOutput: 82.5, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
42- {Provider: "amazon-bedrock", ID: "au.anthropic.claude-opus-4-8", DisplayName: "AU Anthropic Claude Opus 4.8", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 16.5, PriceOutput: 82.5, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
42+ {Provider: "amazon-bedrock", ID: "au.anthropic.claude-opus-4-6-v1", DisplayName: "AU Anthropic Claude Opus 4.6", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
43+ {Provider: "amazon-bedrock", ID: "au.anthropic.claude-opus-4-8", DisplayName: "AU Anthropic Claude Opus 4.8", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
44+ {Provider: "amazon-bedrock", ID: "au.anthropic.claude-opus-5-5", DisplayName: "Claude Opus 5.5 (AU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 4, PriceOutput: 20, PriceCacheRead: 0.2, PriceCacheWrite: 5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
4345 {Provider: "amazon-bedrock", ID: "au.anthropic.claude-sonnet-4-5-20250929-v1:0", DisplayName: "Claude Sonnet 4.5 (AU)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
44- {Provider: "amazon-bedrock", ID: "au.anthropic.claude-sonnet-4-6", DisplayName: "AU Anthropic Claude Sonnet 4.6", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 3.3, PriceOutput: 16.5, PriceCacheRead: 0.33, PriceCacheWrite: 4.125, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
46+ {Provider: "amazon-bedrock", ID: "au.anthropic.claude-sonnet-4-6", DisplayName: "AU Anthropic Claude Sonnet 4.6", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
4547 {Provider: "amazon-bedrock", ID: "au.anthropic.claude-sonnet-5", DisplayName: "Claude Sonnet 5 (AU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
4648 {Provider: "amazon-bedrock", ID: "deepseek.r1-v1:0", DisplayName: "DeepSeek-R1", ContextWindow: 128000, MaxOutput: 32768, Reasoning: true, PriceInput: 1.35, PriceOutput: 5.4, PriceCacheRead: 0, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
4749 {Provider: "amazon-bedrock", ID: "deepseek.v3-v1:0", DisplayName: "DeepSeek-V3.1", ContextWindow: 163840, MaxOutput: 81920, Reasoning: true, PriceInput: 0.58, PriceOutput: 1.68, PriceCacheRead: 0, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
@@ -52,6 +54,7 @@ var builtinCatalog = []Model{
5254 {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-opus-4-6-v1", DisplayName: "Claude Opus 4.6 (EU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},
5355 {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-opus-4-7", DisplayName: "Claude Opus 4.7 (EU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},
5456 {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-opus-4-8", DisplayName: "Claude Opus 4.8 (EU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},
57+ {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-opus-5-5", DisplayName: "Claude Opus 5.5 (EU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 4, PriceOutput: 20, PriceCacheRead: 0.2, PriceCacheWrite: 5, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},
5558 {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", DisplayName: "Claude Sonnet 4.5 (EU)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},
5659 {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6 (EU)", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},
5760 {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-sonnet-5", DisplayName: "Claude Sonnet 5 (EU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},
@@ -62,6 +65,7 @@ var builtinCatalog = []Model{
6265 {Provider: "amazon-bedrock", ID: "global.anthropic.claude-opus-4-7", DisplayName: "Claude Opus 4.7 (Global)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
6366 {Provider: "amazon-bedrock", ID: "global.anthropic.claude-opus-4-8", DisplayName: "Claude Opus 4.8 (Global)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
6467 {Provider: "amazon-bedrock", ID: "global.anthropic.claude-opus-5", DisplayName: "Claude Opus 5 (Global)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
68+ {Provider: "amazon-bedrock", ID: "global.anthropic.claude-opus-5-5", DisplayName: "Claude Opus 5.5 (Global)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 4, PriceOutput: 20, PriceCacheRead: 0.2, PriceCacheWrite: 5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
6569 {Provider: "amazon-bedrock", ID: "global.anthropic.claude-sonnet-4-5-20250929-v1:0", DisplayName: "Claude Sonnet 4.5 (Global)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
6670 {Provider: "amazon-bedrock", ID: "global.anthropic.claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6 (Global)", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
6771 {Provider: "amazon-bedrock", ID: "global.anthropic.claude-sonnet-5", DisplayName: "Claude Sonnet 5 (Global)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
@@ -569,6 +573,7 @@ var builtinCatalog = []Model{
569573 {Provider: "amazon-bedrock", ID: "google.gemma-3-4b-it", DisplayName: "Gemma 3 4B IT", ContextWindow: 128000, MaxOutput: 4096, Reasoning: false, PriceInput: 0.04, PriceOutput: 0.08, PriceCacheRead: 0, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
570574 {Provider: "amazon-bedrock", ID: "jp.anthropic.claude-opus-4-7", DisplayName: "Claude Opus 4.7 (JP)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
571575 {Provider: "amazon-bedrock", ID: "jp.anthropic.claude-opus-4-8", DisplayName: "Claude Opus 4.8 (JP)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
576+ {Provider: "amazon-bedrock", ID: "jp.anthropic.claude-opus-5-5", DisplayName: "Claude Opus 5.5 (JP)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 4, PriceOutput: 20, PriceCacheRead: 0.2, PriceCacheWrite: 5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
572577 {Provider: "amazon-bedrock", ID: "jp.anthropic.claude-sonnet-4-5-20250929-v1:0", DisplayName: "Claude Sonnet 4.5 (JP)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
573578 {Provider: "amazon-bedrock", ID: "jp.anthropic.claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6 (JP)", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
574579 {Provider: "amazon-bedrock", ID: "jp.anthropic.claude-sonnet-5", DisplayName: "Claude Sonnet 5 (JP)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
@@ -613,6 +618,7 @@ var builtinCatalog = []Model{
613618 {Provider: "amazon-bedrock", ID: "us.anthropic.claude-opus-4-6-v1", DisplayName: "Claude Opus 4.6 (US)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
614619 {Provider: "amazon-bedrock", ID: "us.anthropic.claude-opus-4-7", DisplayName: "Claude Opus 4.7 (US)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
615620 {Provider: "amazon-bedrock", ID: "us.anthropic.claude-opus-4-8", DisplayName: "Claude Opus 4.8 (US)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
621+ {Provider: "amazon-bedrock", ID: "us.anthropic.claude-opus-5-5", DisplayName: "Claude Opus 5.5 (US)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 4, PriceOutput: 20, PriceCacheRead: 0.2, PriceCacheWrite: 5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
616622 {Provider: "amazon-bedrock", ID: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", DisplayName: "Claude Sonnet 4.5 (US)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
617623 {Provider: "amazon-bedrock", ID: "us.anthropic.claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6 (US)", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
618624 {Provider: "amazon-bedrock", ID: "us.anthropic.claude-sonnet-5", DisplayName: "Claude Sonnet 5 (US)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
@@ -34,14 +34,16 @@ var builtinCatalog = []Model{
34 {Provider: "amazon-bedrock", ID: "anthropic.claude-opus-4-6-v1", DisplayName: "Claude Opus 4.6", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},34 {Provider: "amazon-bedrock", ID: "anthropic.claude-opus-4-6-v1", DisplayName: "Claude Opus 4.6", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
35 {Provider: "amazon-bedrock", ID: "anthropic.claude-opus-4-7", DisplayName: "Claude Opus 4.7", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},35 {Provider: "amazon-bedrock", ID: "anthropic.claude-opus-4-7", DisplayName: "Claude Opus 4.7", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
36 {Provider: "amazon-bedrock", ID: "anthropic.claude-opus-4-8", DisplayName: "Claude Opus 4.8", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},36 {Provider: "amazon-bedrock", ID: "anthropic.claude-opus-4-8", DisplayName: "Claude Opus 4.8", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
37+ {Provider: "amazon-bedrock", ID: "anthropic.claude-opus-5-5", DisplayName: "Claude Opus 5.5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 4, PriceOutput: 20, PriceCacheRead: 0.2, PriceCacheWrite: 5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
37 {Provider: "amazon-bedrock", ID: "anthropic.claude-sonnet-4-5-20250929-v1:0", DisplayName: "Claude Sonnet 4.5", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},38 {Provider: "amazon-bedrock", ID: "anthropic.claude-sonnet-4-5-20250929-v1:0", DisplayName: "Claude Sonnet 4.5", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
38 {Provider: "amazon-bedrock", ID: "anthropic.claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},39 {Provider: "amazon-bedrock", ID: "anthropic.claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
39 {Provider: "amazon-bedrock", ID: "anthropic.claude-sonnet-5", DisplayName: "Claude Sonnet 5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},40 {Provider: "amazon-bedrock", ID: "anthropic.claude-sonnet-5", DisplayName: "Claude Sonnet 5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
40 {Provider: "amazon-bedrock", ID: "au.anthropic.claude-haiku-4-5-20251001-v1:0", DisplayName: "Claude Haiku 4.5 (AU)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 1, PriceOutput: 5, PriceCacheRead: 0.1, PriceCacheWrite: 1.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},41 {Provider: "amazon-bedrock", ID: "au.anthropic.claude-haiku-4-5-20251001-v1:0", DisplayName: "Claude Haiku 4.5 (AU)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 1, PriceOutput: 5, PriceCacheRead: 0.1, PriceCacheWrite: 1.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
41- {Provider: "amazon-bedrock", ID: "au.anthropic.claude-opus-4-6-v1", DisplayName: "AU Anthropic Claude Opus 4.6", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 16.5, PriceOutput: 82.5, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},42+ {Provider: "amazon-bedrock", ID: "au.anthropic.claude-opus-4-6-v1", DisplayName: "AU Anthropic Claude Opus 4.6", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
42- {Provider: "amazon-bedrock", ID: "au.anthropic.claude-opus-4-8", DisplayName: "AU Anthropic Claude Opus 4.8", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 16.5, PriceOutput: 82.5, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},43+ {Provider: "amazon-bedrock", ID: "au.anthropic.claude-opus-4-8", DisplayName: "AU Anthropic Claude Opus 4.8", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
44+ {Provider: "amazon-bedrock", ID: "au.anthropic.claude-opus-5-5", DisplayName: "Claude Opus 5.5 (AU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 4, PriceOutput: 20, PriceCacheRead: 0.2, PriceCacheWrite: 5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
43 {Provider: "amazon-bedrock", ID: "au.anthropic.claude-sonnet-4-5-20250929-v1:0", DisplayName: "Claude Sonnet 4.5 (AU)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},45 {Provider: "amazon-bedrock", ID: "au.anthropic.claude-sonnet-4-5-20250929-v1:0", DisplayName: "Claude Sonnet 4.5 (AU)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
44- {Provider: "amazon-bedrock", ID: "au.anthropic.claude-sonnet-4-6", DisplayName: "AU Anthropic Claude Sonnet 4.6", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 3.3, PriceOutput: 16.5, PriceCacheRead: 0.33, PriceCacheWrite: 4.125, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},46+ {Provider: "amazon-bedrock", ID: "au.anthropic.claude-sonnet-4-6", DisplayName: "AU Anthropic Claude Sonnet 4.6", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
45 {Provider: "amazon-bedrock", ID: "au.anthropic.claude-sonnet-5", DisplayName: "Claude Sonnet 5 (AU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},47 {Provider: "amazon-bedrock", ID: "au.anthropic.claude-sonnet-5", DisplayName: "Claude Sonnet 5 (AU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
46 {Provider: "amazon-bedrock", ID: "deepseek.r1-v1:0", DisplayName: "DeepSeek-R1", ContextWindow: 128000, MaxOutput: 32768, Reasoning: true, PriceInput: 1.35, PriceOutput: 5.4, PriceCacheRead: 0, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},48 {Provider: "amazon-bedrock", ID: "deepseek.r1-v1:0", DisplayName: "DeepSeek-R1", ContextWindow: 128000, MaxOutput: 32768, Reasoning: true, PriceInput: 1.35, PriceOutput: 5.4, PriceCacheRead: 0, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
47 {Provider: "amazon-bedrock", ID: "deepseek.v3-v1:0", DisplayName: "DeepSeek-V3.1", ContextWindow: 163840, MaxOutput: 81920, Reasoning: true, PriceInput: 0.58, PriceOutput: 1.68, PriceCacheRead: 0, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},49 {Provider: "amazon-bedrock", ID: "deepseek.v3-v1:0", DisplayName: "DeepSeek-V3.1", ContextWindow: 163840, MaxOutput: 81920, Reasoning: true, PriceInput: 0.58, PriceOutput: 1.68, PriceCacheRead: 0, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
@@ -52,6 +54,7 @@ var builtinCatalog = []Model{
52 {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-opus-4-6-v1", DisplayName: "Claude Opus 4.6 (EU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},54 {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-opus-4-6-v1", DisplayName: "Claude Opus 4.6 (EU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},
53 {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-opus-4-7", DisplayName: "Claude Opus 4.7 (EU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},55 {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-opus-4-7", DisplayName: "Claude Opus 4.7 (EU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},
54 {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-opus-4-8", DisplayName: "Claude Opus 4.8 (EU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},56 {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-opus-4-8", DisplayName: "Claude Opus 4.8 (EU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},
57+ {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-opus-5-5", DisplayName: "Claude Opus 5.5 (EU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 4, PriceOutput: 20, PriceCacheRead: 0.2, PriceCacheWrite: 5, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},
55 {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", DisplayName: "Claude Sonnet 4.5 (EU)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},58 {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", DisplayName: "Claude Sonnet 4.5 (EU)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},
56 {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6 (EU)", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},59 {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6 (EU)", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},
57 {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-sonnet-5", DisplayName: "Claude Sonnet 5 (EU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},60 {Provider: "amazon-bedrock", ID: "eu.anthropic.claude-sonnet-5", DisplayName: "Claude Sonnet 5 (EU)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5, BaseURL: "https://bedrock-runtime.eu-central-1.amazonaws.com"},
@@ -62,6 +65,7 @@ var builtinCatalog = []Model{
62 {Provider: "amazon-bedrock", ID: "global.anthropic.claude-opus-4-7", DisplayName: "Claude Opus 4.7 (Global)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},65 {Provider: "amazon-bedrock", ID: "global.anthropic.claude-opus-4-7", DisplayName: "Claude Opus 4.7 (Global)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
63 {Provider: "amazon-bedrock", ID: "global.anthropic.claude-opus-4-8", DisplayName: "Claude Opus 4.8 (Global)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},66 {Provider: "amazon-bedrock", ID: "global.anthropic.claude-opus-4-8", DisplayName: "Claude Opus 4.8 (Global)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
64 {Provider: "amazon-bedrock", ID: "global.anthropic.claude-opus-5", DisplayName: "Claude Opus 5 (Global)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},67 {Provider: "amazon-bedrock", ID: "global.anthropic.claude-opus-5", DisplayName: "Claude Opus 5 (Global)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
68+ {Provider: "amazon-bedrock", ID: "global.anthropic.claude-opus-5-5", DisplayName: "Claude Opus 5.5 (Global)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 4, PriceOutput: 20, PriceCacheRead: 0.2, PriceCacheWrite: 5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
65 {Provider: "amazon-bedrock", ID: "global.anthropic.claude-sonnet-4-5-20250929-v1:0", DisplayName: "Claude Sonnet 4.5 (Global)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},69 {Provider: "amazon-bedrock", ID: "global.anthropic.claude-sonnet-4-5-20250929-v1:0", DisplayName: "Claude Sonnet 4.5 (Global)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
66 {Provider: "amazon-bedrock", ID: "global.anthropic.claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6 (Global)", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},70 {Provider: "amazon-bedrock", ID: "global.anthropic.claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6 (Global)", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
67 {Provider: "amazon-bedrock", ID: "global.anthropic.claude-sonnet-5", DisplayName: "Claude Sonnet 5 (Global)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},71 {Provider: "amazon-bedrock", ID: "global.anthropic.claude-sonnet-5", DisplayName: "Claude Sonnet 5 (Global)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
@@ -569,6 +573,7 @@ var builtinCatalog = []Model{
569 {Provider: "amazon-bedrock", ID: "google.gemma-3-4b-it", DisplayName: "Gemma 3 4B IT", ContextWindow: 128000, MaxOutput: 4096, Reasoning: false, PriceInput: 0.04, PriceOutput: 0.08, PriceCacheRead: 0, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},573 {Provider: "amazon-bedrock", ID: "google.gemma-3-4b-it", DisplayName: "Gemma 3 4B IT", ContextWindow: 128000, MaxOutput: 4096, Reasoning: false, PriceInput: 0.04, PriceOutput: 0.08, PriceCacheRead: 0, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
570 {Provider: "amazon-bedrock", ID: "jp.anthropic.claude-opus-4-7", DisplayName: "Claude Opus 4.7 (JP)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},574 {Provider: "amazon-bedrock", ID: "jp.anthropic.claude-opus-4-7", DisplayName: "Claude Opus 4.7 (JP)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
571 {Provider: "amazon-bedrock", ID: "jp.anthropic.claude-opus-4-8", DisplayName: "Claude Opus 4.8 (JP)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},575 {Provider: "amazon-bedrock", ID: "jp.anthropic.claude-opus-4-8", DisplayName: "Claude Opus 4.8 (JP)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
576+ {Provider: "amazon-bedrock", ID: "jp.anthropic.claude-opus-5-5", DisplayName: "Claude Opus 5.5 (JP)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 4, PriceOutput: 20, PriceCacheRead: 0.2, PriceCacheWrite: 5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
572 {Provider: "amazon-bedrock", ID: "jp.anthropic.claude-sonnet-4-5-20250929-v1:0", DisplayName: "Claude Sonnet 4.5 (JP)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},577 {Provider: "amazon-bedrock", ID: "jp.anthropic.claude-sonnet-4-5-20250929-v1:0", DisplayName: "Claude Sonnet 4.5 (JP)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
573 {Provider: "amazon-bedrock", ID: "jp.anthropic.claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6 (JP)", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},578 {Provider: "amazon-bedrock", ID: "jp.anthropic.claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6 (JP)", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
574 {Provider: "amazon-bedrock", ID: "jp.anthropic.claude-sonnet-5", DisplayName: "Claude Sonnet 5 (JP)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},579 {Provider: "amazon-bedrock", ID: "jp.anthropic.claude-sonnet-5", DisplayName: "Claude Sonnet 5 (JP)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
@@ -613,6 +618,7 @@ var builtinCatalog = []Model{
613 {Provider: "amazon-bedrock", ID: "us.anthropic.claude-opus-4-6-v1", DisplayName: "Claude Opus 4.6 (US)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},618 {Provider: "amazon-bedrock", ID: "us.anthropic.claude-opus-4-6-v1", DisplayName: "Claude Opus 4.6 (US)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
614 {Provider: "amazon-bedrock", ID: "us.anthropic.claude-opus-4-7", DisplayName: "Claude Opus 4.7 (US)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},619 {Provider: "amazon-bedrock", ID: "us.anthropic.claude-opus-4-7", DisplayName: "Claude Opus 4.7 (US)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
615 {Provider: "amazon-bedrock", ID: "us.anthropic.claude-opus-4-8", DisplayName: "Claude Opus 4.8 (US)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},620 {Provider: "amazon-bedrock", ID: "us.anthropic.claude-opus-4-8", DisplayName: "Claude Opus 4.8 (US)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
621+ {Provider: "amazon-bedrock", ID: "us.anthropic.claude-opus-5-5", DisplayName: "Claude Opus 5.5 (US)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 4, PriceOutput: 20, PriceCacheRead: 0.2, PriceCacheWrite: 5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
616 {Provider: "amazon-bedrock", ID: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", DisplayName: "Claude Sonnet 4.5 (US)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},622 {Provider: "amazon-bedrock", ID: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", DisplayName: "Claude Sonnet 4.5 (US)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
617 {Provider: "amazon-bedrock", ID: "us.anthropic.claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6 (US)", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},623 {Provider: "amazon-bedrock", ID: "us.anthropic.claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6 (US)", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
618 {Provider: "amazon-bedrock", ID: "us.anthropic.claude-sonnet-5", DisplayName: "Claude Sonnet 5 (US)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},624 {Provider: "amazon-bedrock", ID: "us.anthropic.claude-sonnet-5", DisplayName: "Claude Sonnet 5 (US)", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com"},
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+ "net/url"
39+ "strings"
40+ "time"
41+)
42+
43+// bytesReader returns a reader for payload, or nil when payload is nil
44+// so GET requests carry no body.
45+func bytesReader(payload []byte) io.Reader {
46+ if payload == nil {
47+ return nil
48+ }
49+ return bytes.NewReader(payload)
50+}
51+
52+// DiscoverBedrock lists the foundation models and inference profiles
53+// available to the caller's AWS credentials in the given region.
54+//
55+// SigV4 credentials are resolved from the standard AWS environment /
56+// profile / CLI sources, even when a bearer token is used for inference.
57+//
58+// region defaults to AWS_REGION / AWS_DEFAULT_REGION / us-east-1 when
59+// empty. A nil error with a nil slice means "no SigV4 credentials" or
60+// "no models"; callers should treat that as a skip.
61+func DiscoverBedrock(ctx context.Context, region string) ([]Model, error) {
62+ if region == "" {
63+ region = bedrockResolveRegion()
64+ }
65+ sigv4 := resolveBedrockDiscoveryCreds()
66+ if sigv4 == nil {
67+ // Bearer-only or unauthenticated: control-plane APIs are
68+ // unreachable. Skip quietly so the catalog stands.
69+ return nil, nil
70+ }
71+
72+ client := &http.Client{Timeout: 15 * time.Second}
73+ ids, err := bedrockListModelIDs(ctx, client, sigv4, region, "https://bedrock."+region+".amazonaws.com")
74+ if err != nil {
75+ return nil, err
76+ }
77+ if len(ids) == 0 {
78+ return nil, nil
79+ }
80+
81+ out := make([]Model, 0, len(ids))
82+ for _, id := range ids {
83+ out = append(out, Model{
84+ Provider: "amazon-bedrock",
85+ ID: id,
86+ Source: "live",
87+ BaseURL: "https://bedrock-runtime." + region + ".amazonaws.com",
88+ })
89+ }
90+ return out, nil
91+}
92+
93+// bedrockListModelIDs enumerates foundation-model IDs and inference-
94+// profile IDs available in the region. Profile IDs are the geo-prefixed
95+// forms zot invokes; both are returned so the picker shows every
96+// selectable variant. controlPlaneBase is the scheme+host (no trailing
97+// slash) of the Bedrock control plane, parameterized for testing.
98+func bedrockListModelIDs(ctx context.Context, client *http.Client, creds *bedrockSigV4Creds, region, controlPlaneBase string) ([]string, error) {
99+ seen := map[string]bool{}
100+ var ids []string
101+ add := func(id string) {
102+ id = strings.TrimSpace(id)
103+ if id == "" || seen[id] {
104+ return
105+ }
106+ seen[id] = true
107+ ids = append(ids, id)
108+ }
109+
110+ // ListFoundationModels: GET, no pagination token in the response.
111+ fmURL := controlPlaneBase + "/foundation-models"
112+ body, err := bedrockSignedControlPlane(ctx, client, creds, region, http.MethodGet, fmURL, nil)
113+ if err != nil {
114+ return nil, err
115+ }
116+ var fm struct {
117+ ModelSummaries []struct {
118+ ModelID string `json:"modelId"`
119+ OutputModalities []string `json:"outputModalities"`
120+ } `json:"modelSummaries"`
121+ }
122+ if err := json.Unmarshal(body, &fm); err != nil {
123+ return nil, fmt.Errorf("bedrock discover: parse foundation-models: %w", err)
124+ }
125+ for _, s := range fm.ModelSummaries {
126+ // Keep text-output models only; skip embeddings/image generators.
127+ if !bedrockEmitsText(s.OutputModalities) {
128+ continue
129+ }
130+ add(s.ModelID)
131+ }
132+
133+ // ListInferenceProfiles: GET, paginated via ?nextToken=.
134+ next := ""
135+ for {
136+ query := url.Values{"maxResults": {"1000"}}
137+ if next != "" {
138+ query.Set("nextToken", next)
139+ }
140+ ipURL := controlPlaneBase + "/inference-profiles?" + query.Encode()
141+ ipBody, err := bedrockSignedControlPlane(ctx, client, creds, region, http.MethodGet, ipURL, nil)
142+ if err != nil {
143+ // Non-fatal: foundation models already gathered.
144+ break
145+ }
146+ var ip struct {
147+ InferenceProfileSummaries []struct {
148+ InferenceProfileID string `json:"inferenceProfileId"`
149+ } `json:"inferenceProfileSummaries"`
150+ NextToken string `json:"nextToken"`
151+ }
152+ if err := json.Unmarshal(ipBody, &ip); err != nil {
153+ break
154+ }
155+ for _, s := range ip.InferenceProfileSummaries {
156+ add(s.InferenceProfileID)
157+ }
158+ if ip.NextToken == "" {
159+ break
160+ }
161+ next = ip.NextToken
162+ }
163+
164+ return ids, nil
165+}
166+
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+ "net/url"
39+ "strings"
40+ "time"
41+)
42+
43+// bytesReader returns a reader for payload, or nil when payload is nil
44+// so GET requests carry no body.
45+func bytesReader(payload []byte) io.Reader {
46+ if payload == nil {
47+ return nil
48+ }
49+ return bytes.NewReader(payload)
50+}
51+
52+// DiscoverBedrock lists the foundation models and inference profiles
53+// available to the caller's AWS credentials in the given region.
54+//
55+// SigV4 credentials are resolved from the standard AWS environment /
56+// profile / CLI sources, even when a bearer token is used for inference.
57+//
58+// region defaults to AWS_REGION / AWS_DEFAULT_REGION / us-east-1 when
59+// empty. A nil error with a nil slice means "no SigV4 credentials" or
60+// "no models"; callers should treat that as a skip.
61+func DiscoverBedrock(ctx context.Context, region string) ([]Model, error) {
62+ if region == "" {
63+ region = bedrockResolveRegion()
64+ }
65+ sigv4 := resolveBedrockDiscoveryCreds()
66+ if sigv4 == nil {
67+ // Bearer-only or unauthenticated: control-plane APIs are
68+ // unreachable. Skip quietly so the catalog stands.
69+ return nil, nil
70+ }
71+
72+ client := &http.Client{Timeout: 15 * time.Second}
73+ ids, err := bedrockListModelIDs(ctx, client, sigv4, region, "https://bedrock."+region+".amazonaws.com")
74+ if err != nil {
75+ return nil, err
76+ }
77+ if len(ids) == 0 {
78+ return nil, nil
79+ }
80+
81+ out := make([]Model, 0, len(ids))
82+ for _, id := range ids {
83+ out = append(out, Model{
84+ Provider: "amazon-bedrock",
85+ ID: id,
86+ Source: "live",
87+ BaseURL: "https://bedrock-runtime." + region + ".amazonaws.com",
88+ })
89+ }
90+ return out, nil
91+}
92+
93+// bedrockListModelIDs enumerates foundation-model IDs and inference-
94+// profile IDs available in the region. Profile IDs are the geo-prefixed
95+// forms zot invokes; both are returned so the picker shows every
96+// selectable variant. controlPlaneBase is the scheme+host (no trailing
97+// slash) of the Bedrock control plane, parameterized for testing.
98+func bedrockListModelIDs(ctx context.Context, client *http.Client, creds *bedrockSigV4Creds, region, controlPlaneBase string) ([]string, error) {
99+ seen := map[string]bool{}
100+ var ids []string
101+ add := func(id string) {
102+ id = strings.TrimSpace(id)
103+ if id == "" || seen[id] {
104+ return
105+ }
106+ seen[id] = true
107+ ids = append(ids, id)
108+ }
109+
110+ // ListFoundationModels: GET, no pagination token in the response.
111+ fmURL := controlPlaneBase + "/foundation-models"
112+ body, err := bedrockSignedControlPlane(ctx, client, creds, region, http.MethodGet, fmURL, nil)
113+ if err != nil {
114+ return nil, err
115+ }
116+ var fm struct {
117+ ModelSummaries []struct {
118+ ModelID string `json:"modelId"`
119+ OutputModalities []string `json:"outputModalities"`
120+ } `json:"modelSummaries"`
121+ }
122+ if err := json.Unmarshal(body, &fm); err != nil {
123+ return nil, fmt.Errorf("bedrock discover: parse foundation-models: %w", err)
124+ }
125+ for _, s := range fm.ModelSummaries {
126+ // Keep text-output models only; skip embeddings/image generators.
127+ if !bedrockEmitsText(s.OutputModalities) {
128+ continue
129+ }
130+ add(s.ModelID)
131+ }
132+
133+ // ListInferenceProfiles: GET, paginated via ?nextToken=.
134+ next := ""
135+ for {
136+ query := url.Values{"maxResults": {"1000"}}
137+ if next != "" {
138+ query.Set("nextToken", next)
139+ }
140+ ipURL := controlPlaneBase + "/inference-profiles?" + query.Encode()
141+ ipBody, err := bedrockSignedControlPlane(ctx, client, creds, region, http.MethodGet, ipURL, nil)
142+ if err != nil {
143+ // Non-fatal: foundation models already gathered.
144+ break
145+ }
146+ var ip struct {
147+ InferenceProfileSummaries []struct {
148+ InferenceProfileID string `json:"inferenceProfileId"`
149+ } `json:"inferenceProfileSummaries"`
150+ NextToken string `json:"nextToken"`
151+ }
152+ if err := json.Unmarshal(ipBody, &ip); err != nil {
153+ break
154+ }
155+ for _, s := range ip.InferenceProfileSummaries {
156+ add(s.InferenceProfileID)
157+ }
158+ if ip.NextToken == "" {
159+ break
160+ }
161+ next = ip.NextToken
162+ }
163+
164+ return ids, nil
165+}
166+
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 +171 -0
@@ -0,0 +1,171 @@
1+package provider
2+
3+import (
4+ "context"
5+ "io"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+)
11+
12+type bedrockDiscoveryTestTransport func(*http.Request) (*http.Response, error)
13+
14+func (f bedrockDiscoveryTestTransport) RoundTrip(r *http.Request) (*http.Response, error) {
15+ return f(r)
16+}
17+
18+func TestDiscoverBedrockUsesSigV4WithBearer(t *testing.T) {
19+ t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "bearer")
20+ t.Setenv("AWS_ACCESS_KEY_ID", "AKID")
21+ t.Setenv("AWS_SECRET_ACCESS_KEY", "SECRET")
22+ original := http.DefaultTransport
23+ t.Cleanup(func() { http.DefaultTransport = original })
24+ http.DefaultTransport = bedrockDiscoveryTestTransport(func(r *http.Request) (*http.Response, error) {
25+ if !strings.Contains(r.Header.Get("Authorization"), "Credential=AKID/") {
26+ t.Errorf("discovery request was not signed with SigV4")
27+ }
28+ body := `{"modelSummaries":[{"modelId":"test.new-model","outputModalities":["TEXT"]}]}`
29+ if r.URL.Path == "/inference-profiles" {
30+ body = `{"inferenceProfileSummaries":[]}`
31+ }
32+ return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
33+ })
34+ models, err := DiscoverBedrock(context.Background(), "us-east-1")
35+ if err != nil || len(models) != 1 || models[0].ID != "test.new-model" {
36+ t.Fatalf("discovered models = %v, err = %v", models, err)
37+ }
38+ if bearer, sigv4 := resolveBedrockAuth("bearer"); bearer != "bearer" || sigv4 != nil {
39+ t.Fatal("inference must continue to prefer the bearer token")
40+ }
41+}
42+
43+func TestBedrockEmitsText(t *testing.T) {
44+ if !bedrockEmitsText(nil) {
45+ t.Error("nil modalities should default to text")
46+ }
47+ if !bedrockEmitsText([]string{"TEXT"}) {
48+ t.Error("TEXT should emit text")
49+ }
50+ if bedrockEmitsText([]string{"IMAGE"}) {
51+ t.Error("IMAGE-only should not emit text")
52+ }
53+ if !bedrockEmitsText([]string{"IMAGE", "TEXT"}) {
54+ t.Error("mixed with TEXT should emit text")
55+ }
56+}
57+
58+func TestResolveBedrockAuthBearerVsSigV4(t *testing.T) {
59+ t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "")
60+ t.Setenv("AWS_ACCESS_KEY_ID", "")
61+ t.Setenv("AWS_SECRET_ACCESS_KEY", "")
62+ t.Setenv("AWS_SESSION_TOKEN", "")
63+ t.Setenv("AWS_PROFILE", "")
64+
65+ // Explicit bearer via apiKey.
66+ if b, s := resolveBedrockAuth("tok-123"); b != "tok-123" || s != nil {
67+ t.Errorf("explicit bearer: got (%q,%v)", b, s)
68+ }
69+
70+ // Sentinel <aws> is not a bearer; falls through to SigV4 env.
71+ t.Setenv("AWS_ACCESS_KEY_ID", "AKID")
72+ t.Setenv("AWS_SECRET_ACCESS_KEY", "SECRET")
73+ b, s := resolveBedrockAuth("<aws>")
74+ if b != "" || s == nil {
75+ t.Fatalf("sentinel should resolve SigV4, got (%q,%v)", b, s)
76+ }
77+ if s.accessKeyID != "AKID" || s.secretAccessKey != "SECRET" {
78+ t.Errorf("sigv4 creds = %+v", s)
79+ }
80+}
81+
82+// TestBedrockListModelIDs parses control-plane responses shaped exactly
83+// like the real ones captured from a live account (see the doc note in
84+// discover_bedrock.go): ListFoundationModels returns modelSummaries with
85+// modelId + outputModalities; ListInferenceProfiles returns
86+// inferenceProfileSummaries with inferenceProfileId and paginates via
87+// nextToken. Embedding/image-only models are filtered out; profile IDs
88+// are included; duplicates are de-duplicated.
89+func TestBedrockListModelIDs(t *testing.T) {
90+ page1 := `{"inferenceProfileSummaries":[{"inferenceProfileId":"us.anthropic.claude-opus-5-5"}],"nextToken":"N+E/T= &"}`
91+ page2 := `{"inferenceProfileSummaries":[{"inferenceProfileId":"eu.anthropic.claude-sonnet-5"}]}`
92+ foundation := `{"modelSummaries":[
93+ {"modelId":"anthropic.claude-opus-5-5","outputModalities":["TEXT"]},
94+ {"modelId":"amazon.titan-embed-text-v2","outputModalities":["EMBEDDING"]},
95+ {"modelId":"stability.sd3-large","outputModalities":["IMAGE"]},
96+ {"modelId":"anthropic.claude-opus-5-5","outputModalities":["TEXT"]}
97+ ]}`
98+
99+ var ipHits int
100+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
101+ w.Header().Set("content-type", "application/json")
102+ switch {
103+ case strings.Contains(r.URL.Path, "/foundation-models"):
104+ w.Write([]byte(foundation))
105+ case strings.Contains(r.URL.Path, "/inference-profiles"):
106+ ipHits++
107+ switch token := r.URL.Query().Get("nextToken"); token {
108+ case "":
109+ w.Write([]byte(page1))
110+ case "N+E/T= &":
111+ w.Write([]byte(page2))
112+ default:
113+ t.Errorf("unexpected pagination token %q", token)
114+ w.WriteHeader(http.StatusBadRequest)
115+ }
116+ default:
117+ w.WriteHeader(http.StatusNotFound)
118+ }
119+ }))
120+ defer srv.Close()
121+
122+ creds := &bedrockSigV4Creds{accessKeyID: "AKID", secretAccessKey: "SECRET"}
123+ ids, err := bedrockListModelIDs(context.Background(), srv.Client(), creds, "us-east-1", srv.URL)
124+ if err != nil {
125+ t.Fatalf("bedrockListModelIDs: %v", err)
126+ }
127+
128+ want := []string{
129+ "anthropic.claude-opus-5-5", // text foundation model, de-duplicated
130+ "us.anthropic.claude-opus-5-5", // profile page 1
131+ "eu.anthropic.claude-sonnet-5", // profile page 2 (pagination followed)
132+ }
133+ got := map[string]int{}
134+ for _, id := range ids {
135+ got[id]++
136+ }
137+ for _, w := range want {
138+ if got[w] != 1 {
139+ t.Errorf("expected exactly one %q, got %d (all=%v)", w, got[w], ids)
140+ }
141+ }
142+ // Embedding and image-only models must be filtered out.
143+ for _, bad := range []string{"amazon.titan-embed-text-v2", "stability.sd3-large"} {
144+ if got[bad] != 0 {
145+ t.Errorf("non-text model %q should be filtered out", bad)
146+ }
147+ }
148+ if ipHits != 2 {
149+ t.Errorf("expected 2 inference-profile page requests (pagination), got %d", ipHits)
150+ }
151+}
152+
153+// TestBedrockControlPlaneHTTPError confirms non-2xx responses surface as
154+// errors with the status and body, so discovery fails loudly (and the
155+// caller then falls back to the catalog) rather than silently parsing junk.
156+func TestBedrockControlPlaneHTTPError(t *testing.T) {
157+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
158+ w.WriteHeader(http.StatusForbidden)
159+ w.Write([]byte(`{"message":"not authorized"}`))
160+ }))
161+ defer srv.Close()
162+
163+ creds := &bedrockSigV4Creds{accessKeyID: "AKID", secretAccessKey: "SECRET"}
164+ _, err := bedrockSignedControlPlane(context.Background(), srv.Client(), creds, "us-east-1", http.MethodGet, srv.URL+"/foundation-models", nil)
165+ if err == nil {
166+ t.Fatal("expected error on 403")
167+ }
168+ if !strings.Contains(err.Error(), "403") || !strings.Contains(err.Error(), "not authorized") {
169+ t.Errorf("error should carry status and body: %v", err)
170+ }
171+}
@@ -0,0 +1,171 @@
1+package provider
2+
3+import (
4+ "context"
5+ "io"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+)
11+
12+type bedrockDiscoveryTestTransport func(*http.Request) (*http.Response, error)
13+
14+func (f bedrockDiscoveryTestTransport) RoundTrip(r *http.Request) (*http.Response, error) {
15+ return f(r)
16+}
17+
18+func TestDiscoverBedrockUsesSigV4WithBearer(t *testing.T) {
19+ t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "bearer")
20+ t.Setenv("AWS_ACCESS_KEY_ID", "AKID")
21+ t.Setenv("AWS_SECRET_ACCESS_KEY", "SECRET")
22+ original := http.DefaultTransport
23+ t.Cleanup(func() { http.DefaultTransport = original })
24+ http.DefaultTransport = bedrockDiscoveryTestTransport(func(r *http.Request) (*http.Response, error) {
25+ if !strings.Contains(r.Header.Get("Authorization"), "Credential=AKID/") {
26+ t.Errorf("discovery request was not signed with SigV4")
27+ }
28+ body := `{"modelSummaries":[{"modelId":"test.new-model","outputModalities":["TEXT"]}]}`
29+ if r.URL.Path == "/inference-profiles" {
30+ body = `{"inferenceProfileSummaries":[]}`
31+ }
32+ return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
33+ })
34+ models, err := DiscoverBedrock(context.Background(), "us-east-1")
35+ if err != nil || len(models) != 1 || models[0].ID != "test.new-model" {
36+ t.Fatalf("discovered models = %v, err = %v", models, err)
37+ }
38+ if bearer, sigv4 := resolveBedrockAuth("bearer"); bearer != "bearer" || sigv4 != nil {
39+ t.Fatal("inference must continue to prefer the bearer token")
40+ }
41+}
42+
43+func TestBedrockEmitsText(t *testing.T) {
44+ if !bedrockEmitsText(nil) {
45+ t.Error("nil modalities should default to text")
46+ }
47+ if !bedrockEmitsText([]string{"TEXT"}) {
48+ t.Error("TEXT should emit text")
49+ }
50+ if bedrockEmitsText([]string{"IMAGE"}) {
51+ t.Error("IMAGE-only should not emit text")
52+ }
53+ if !bedrockEmitsText([]string{"IMAGE", "TEXT"}) {
54+ t.Error("mixed with TEXT should emit text")
55+ }
56+}
57+
58+func TestResolveBedrockAuthBearerVsSigV4(t *testing.T) {
59+ t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "")
60+ t.Setenv("AWS_ACCESS_KEY_ID", "")
61+ t.Setenv("AWS_SECRET_ACCESS_KEY", "")
62+ t.Setenv("AWS_SESSION_TOKEN", "")
63+ t.Setenv("AWS_PROFILE", "")
64+
65+ // Explicit bearer via apiKey.
66+ if b, s := resolveBedrockAuth("tok-123"); b != "tok-123" || s != nil {
67+ t.Errorf("explicit bearer: got (%q,%v)", b, s)
68+ }
69+
70+ // Sentinel <aws> is not a bearer; falls through to SigV4 env.
71+ t.Setenv("AWS_ACCESS_KEY_ID", "AKID")
72+ t.Setenv("AWS_SECRET_ACCESS_KEY", "SECRET")
73+ b, s := resolveBedrockAuth("<aws>")
74+ if b != "" || s == nil {
75+ t.Fatalf("sentinel should resolve SigV4, got (%q,%v)", b, s)
76+ }
77+ if s.accessKeyID != "AKID" || s.secretAccessKey != "SECRET" {
78+ t.Errorf("sigv4 creds = %+v", s)
79+ }
80+}
81+
82+// TestBedrockListModelIDs parses control-plane responses shaped exactly
83+// like the real ones captured from a live account (see the doc note in
84+// discover_bedrock.go): ListFoundationModels returns modelSummaries with
85+// modelId + outputModalities; ListInferenceProfiles returns
86+// inferenceProfileSummaries with inferenceProfileId and paginates via
87+// nextToken. Embedding/image-only models are filtered out; profile IDs
88+// are included; duplicates are de-duplicated.
89+func TestBedrockListModelIDs(t *testing.T) {
90+ page1 := `{"inferenceProfileSummaries":[{"inferenceProfileId":"us.anthropic.claude-opus-5-5"}],"nextToken":"N+E/T= &"}`
91+ page2 := `{"inferenceProfileSummaries":[{"inferenceProfileId":"eu.anthropic.claude-sonnet-5"}]}`
92+ foundation := `{"modelSummaries":[
93+ {"modelId":"anthropic.claude-opus-5-5","outputModalities":["TEXT"]},
94+ {"modelId":"amazon.titan-embed-text-v2","outputModalities":["EMBEDDING"]},
95+ {"modelId":"stability.sd3-large","outputModalities":["IMAGE"]},
96+ {"modelId":"anthropic.claude-opus-5-5","outputModalities":["TEXT"]}
97+ ]}`
98+
99+ var ipHits int
100+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
101+ w.Header().Set("content-type", "application/json")
102+ switch {
103+ case strings.Contains(r.URL.Path, "/foundation-models"):
104+ w.Write([]byte(foundation))
105+ case strings.Contains(r.URL.Path, "/inference-profiles"):
106+ ipHits++
107+ switch token := r.URL.Query().Get("nextToken"); token {
108+ case "":
109+ w.Write([]byte(page1))
110+ case "N+E/T= &":
111+ w.Write([]byte(page2))
112+ default:
113+ t.Errorf("unexpected pagination token %q", token)
114+ w.WriteHeader(http.StatusBadRequest)
115+ }
116+ default:
117+ w.WriteHeader(http.StatusNotFound)
118+ }
119+ }))
120+ defer srv.Close()
121+
122+ creds := &bedrockSigV4Creds{accessKeyID: "AKID", secretAccessKey: "SECRET"}
123+ ids, err := bedrockListModelIDs(context.Background(), srv.Client(), creds, "us-east-1", srv.URL)
124+ if err != nil {
125+ t.Fatalf("bedrockListModelIDs: %v", err)
126+ }
127+
128+ want := []string{
129+ "anthropic.claude-opus-5-5", // text foundation model, de-duplicated
130+ "us.anthropic.claude-opus-5-5", // profile page 1
131+ "eu.anthropic.claude-sonnet-5", // profile page 2 (pagination followed)
132+ }
133+ got := map[string]int{}
134+ for _, id := range ids {
135+ got[id]++
136+ }
137+ for _, w := range want {
138+ if got[w] != 1 {
139+ t.Errorf("expected exactly one %q, got %d (all=%v)", w, got[w], ids)
140+ }
141+ }
142+ // Embedding and image-only models must be filtered out.
143+ for _, bad := range []string{"amazon.titan-embed-text-v2", "stability.sd3-large"} {
144+ if got[bad] != 0 {
145+ t.Errorf("non-text model %q should be filtered out", bad)
146+ }
147+ }
148+ if ipHits != 2 {
149+ t.Errorf("expected 2 inference-profile page requests (pagination), got %d", ipHits)
150+ }
151+}
152+
153+// TestBedrockControlPlaneHTTPError confirms non-2xx responses surface as
154+// errors with the status and body, so discovery fails loudly (and the
155+// caller then falls back to the catalog) rather than silently parsing junk.
156+func TestBedrockControlPlaneHTTPError(t *testing.T) {
157+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
158+ w.WriteHeader(http.StatusForbidden)
159+ w.Write([]byte(`{"message":"not authorized"}`))
160+ }))
161+ defer srv.Close()
162+
163+ creds := &bedrockSigV4Creds{accessKeyID: "AKID", secretAccessKey: "SECRET"}
164+ _, err := bedrockSignedControlPlane(context.Background(), srv.Client(), creds, "us-east-1", http.MethodGet, srv.URL+"/foundation-models", nil)
165+ if err == nil {
166+ t.Fatal("expected error on 403")
167+ }
168+ if !strings.Contains(err.Error(), "403") || !strings.Contains(err.Error(), "not authorized") {
169+ t.Errorf("error should carry status and body: %v", err)
170+ }
171+}

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