Merge branch 'main' into issue-212-oauth-state
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 | |||
| 543 | 543 | `--list-models` or the `/model` picker shows the full catalog across all built-in providers. Three sources: | |
| 544 | 544 | ||
| 545 | 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 | 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 | 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 | 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 | 811 | ### Local models with ollama | |
| 788 | 812 | ||
| 789 | 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. | |
| @@ -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 ollama | 811 | ### 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) { | |||
| 596 | 596 | // models like ollama don't need real API keys). | |
| 597 | 597 | if resolvedModel.BaseURL != "" && credErr != nil { | |
| 598 | 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 | 605 | credErr = nil | |
| 600 | 606 | requireCred = false | |
| 601 | 607 | } | |
| @@ -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 = nil | 605 | credErr = nil | ||
| 600 | requireCred = false | 606 | requireCred = false | ||
| 601 | } | 607 | } | ||
M packages/agent/modelsync.go +6 -0
| @@ -255,6 +255,12 @@ func refreshModels() { | |||
| 255 | 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 | 264 | if haveOpenRouter { | |
| 259 | 265 | // /models is public; gate on a credential so the picker only | |
| 260 | 266 | // 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 only | 265 | // /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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "fmt" | |
| 7 | + | "io" | |
| 7 | 8 | "net/http" | |
| 8 | 9 | "net/http/httptest" | |
| 9 | 10 | "os" | |
| 10 | 11 | "path/filepath" | |
| 12 | + | "runtime" | |
| 13 | + | "strings" | |
| 11 | 14 | "testing" | |
| 12 | 15 | "time" | |
| 13 | 16 | ||
| 14 | 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 | 69 | func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) { | |
| 18 | 70 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 19 | 71 | 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() { | |||
| 1411 | 1411 | case i.telegramDialog.Active(): | |
| 1412 | 1412 | dialog = i.telegramDialog.Render(i.cfg.Theme, cols) | |
| 1413 | 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 | 1428 | dialog = i.settingsDialog.Render(i.cfg.Theme, cols) | |
| 1415 | 1429 | case i.sessionOpsDialog.Active(): | |
| 1416 | 1430 | 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 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "strings" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/mattn/go-runewidth" | |
| @@ -18,6 +19,18 @@ type settingsDialog struct { | |||
| 18 | 19 | optionCursor int | |
| 19 | 20 | parentItems []settingsItem | |
| 20 | 21 | 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 | 36 | type settingsItem struct { | |
| @@ -62,6 +75,7 @@ func (d *settingsDialog) Open(items []settingsItem) bool { | |||
| 62 | 75 | d.optionCursor = 0 | |
| 63 | 76 | d.parentItems = nil | |
| 64 | 77 | d.parentCursor = 0 | |
| 78 | + | d.viewTop = 0 | |
| 65 | 79 | d.active = true | |
| 66 | 80 | return true | |
| 67 | 81 | } | |
| @@ -81,6 +95,7 @@ func (d *settingsDialog) OpenDirectOption(item settingsItem) bool { | |||
| 81 | 95 | } | |
| 82 | 96 | d.parentItems = nil | |
| 83 | 97 | d.parentCursor = 0 | |
| 98 | + | d.viewTop = 0 | |
| 84 | 99 | d.active = true | |
| 85 | 100 | return true | |
| 86 | 101 | } | |
| @@ -90,6 +105,7 @@ func (d *settingsDialog) Close() { | |||
| 90 | 105 | d.selecting = false | |
| 91 | 106 | d.direct = false | |
| 92 | 107 | d.parentItems = nil | |
| 108 | + | d.viewTop = 0 | |
| 93 | 109 | } | |
| 94 | 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 | 135 | d.cursor = d.parentCursor | |
| 120 | 136 | d.parentItems = nil | |
| 121 | 137 | d.parentCursor = 0 | |
| 138 | + | d.viewTop = 0 | |
| 122 | 139 | d.title = "settings" | |
| 123 | 140 | return settingsAction{} | |
| 124 | 141 | } | |
| @@ -151,6 +168,7 @@ func (d *settingsDialog) handleOptionKey(k tui.Key) settingsAction { | |||
| 151 | 168 | return settingsAction{Close: true} | |
| 152 | 169 | } | |
| 153 | 170 | d.selecting = false | |
| 171 | + | d.viewTop = 0 | |
| 154 | 172 | case tui.KeyEnter: | |
| 155 | 173 | return d.selectCurrentOption() | |
| 156 | 174 | case tui.KeyRune: | |
| @@ -188,6 +206,7 @@ func (d *settingsDialog) toggleCurrent() settingsAction { | |||
| 188 | 206 | d.items = it.children | |
| 189 | 207 | d.cursor = 0 | |
| 190 | 208 | d.optionCursor = 0 | |
| 209 | + | d.viewTop = 0 | |
| 191 | 210 | d.title = "settings: " + it.label | |
| 192 | 211 | return settingsAction{} | |
| 193 | 212 | } | |
| @@ -197,6 +216,7 @@ func (d *settingsDialog) toggleCurrent() settingsAction { | |||
| 197 | 216 | d.optionCursor = 0 | |
| 198 | 217 | } | |
| 199 | 218 | d.selecting = true | |
| 219 | + | d.viewTop = 0 | |
| 200 | 220 | return settingsAction{} | |
| 201 | 221 | } | |
| 202 | 222 | it.value = !it.value | |
| @@ -220,6 +240,7 @@ func (d *settingsDialog) selectCurrentOption() settingsAction { | |||
| 220 | 240 | it.choice = d.optionCursor | |
| 221 | 241 | d.items[d.cursor] = it | |
| 222 | 242 | d.selecting = false | |
| 243 | + | d.viewTop = 0 | |
| 223 | 244 | action := settingsAction{Toggle: true, Key: it.key, StringValue: it.options[it.choice].value} | |
| 224 | 245 | if d.direct { | |
| 225 | 246 | d.Close() | |
| @@ -242,39 +263,175 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string { | |||
| 242 | 263 | } else { | |
| 243 | 264 | lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:")) | |
| 244 | 265 | } | |
| 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 | |
| 263 | 303 | } | |
| 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] | |
| 270 | 398 | } | |
| 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] | |
| 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 | 435 | return lines | |
| 279 | 436 | } | |
| 280 | 437 | ||
| @@ -289,27 +446,37 @@ func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string { | |||
| 289 | 446 | title = d.title | |
| 290 | 447 | } | |
| 291 | 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 | 452 | lines = append(lines, th.FG256(th.Muted, it.desc)) | |
| 294 | 453 | } | |
| 295 | 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 | 457 | for idx, opt := range it.options { | |
| 297 | 458 | marker := " " | |
| 298 | 459 | if idx == it.choice { | |
| 299 | 460 | marker = "✓ " | |
| 300 | 461 | } | |
| 301 | 462 | plain := " " + marker + opt.label | |
| 463 | + | block := make([]string, 0, 4) | |
| 302 | 464 | if idx == d.optionCursor { | |
| 303 | - | lines = append(lines, th.PadHighlight(plain, width)) | |
| 465 | + | block = append(block, th.PadHighlight(plain, width)) | |
| 304 | 466 | } else { | |
| 305 | - | lines = append(lines, plain) | |
| 467 | + | block = append(block, plain) | |
| 306 | 468 | } | |
| 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)) | |
| 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 | 480 | lines = append(lines, frameRule(th, width)) | |
| 314 | 481 | return lines | |
| 315 | 482 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package modes | 1 | 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 int | 19 | optionCursor int | ||
| 19 | parentItems []settingsItem | 20 | parentItems []settingsItem | ||
| 20 | parentCursor int | 21 | 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 = 0 | 75 | d.optionCursor = 0 | ||
| 63 | d.parentItems = nil | 76 | d.parentItems = nil | ||
| 64 | d.parentCursor = 0 | 77 | d.parentCursor = 0 | ||
| 78 | + | d.viewTop = 0 | |||
| 65 | d.active = true | 79 | d.active = true | ||
| 66 | return true | 80 | return true | ||
| 67 | } | 81 | } | ||
| @@ -81,6 +95,7 @@ func (d *settingsDialog) OpenDirectOption(item settingsItem) bool { | |||||
| 81 | } | 95 | } | ||
| 82 | d.parentItems = nil | 96 | d.parentItems = nil | ||
| 83 | d.parentCursor = 0 | 97 | d.parentCursor = 0 | ||
| 98 | + | d.viewTop = 0 | |||
| 84 | d.active = true | 99 | d.active = true | ||
| 85 | return true | 100 | return true | ||
| 86 | } | 101 | } | ||
| @@ -90,6 +105,7 @@ func (d *settingsDialog) Close() { | |||||
| 90 | d.selecting = false | 105 | d.selecting = false | ||
| 91 | d.direct = false | 106 | d.direct = false | ||
| 92 | d.parentItems = nil | 107 | 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.parentCursor | 135 | d.cursor = d.parentCursor | ||
| 120 | d.parentItems = nil | 136 | d.parentItems = nil | ||
| 121 | d.parentCursor = 0 | 137 | 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 = false | 170 | 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.children | 206 | d.items = it.children | ||
| 189 | d.cursor = 0 | 207 | d.cursor = 0 | ||
| 190 | d.optionCursor = 0 | 208 | d.optionCursor = 0 | ||
| 209 | + | d.viewTop = 0 | |||
| 191 | d.title = "settings: " + it.label | 210 | 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 = 0 | 216 | d.optionCursor = 0 | ||
| 198 | } | 217 | } | ||
| 199 | d.selecting = true | 218 | d.selecting = true | ||
| 219 | + | d.viewTop = 0 | |||
| 200 | return settingsAction{} | 220 | return settingsAction{} | ||
| 201 | } | 221 | } | ||
| 202 | it.value = !it.value | 222 | it.value = !it.value | ||
| @@ -220,6 +240,7 @@ func (d *settingsDialog) selectCurrentOption() settingsAction { | |||||
| 220 | it.choice = d.optionCursor | 240 | it.choice = d.optionCursor | ||
| 221 | d.items[d.cursor] = it | 241 | d.items[d.cursor] = it | ||
| 222 | d.selecting = false | 242 | 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.label | 271 | + | // 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.label | 274 | + | 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 = 0 | 278 | + | 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].label | 280 | + | 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 lines | 435 | return lines | ||
| 279 | } | 436 | } | ||
| 280 | 437 | ||||
| @@ -289,27 +446,37 @@ func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string { | |||||
| 289 | title = d.title | 446 | 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.label | 462 | 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 lines | 481 | 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 ( | |||
| 50 | 50 | "net/http" | |
| 51 | 51 | "net/url" | |
| 52 | 52 | "os" | |
| 53 | + | "os/exec" | |
| 53 | 54 | "sort" | |
| 54 | 55 | "strings" | |
| 55 | 56 | "time" | |
| @@ -82,17 +83,13 @@ type bedrockSigV4Creds struct { | |||
| 82 | 83 | // 3. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ optional | |
| 83 | 84 | // AWS_SESSION_TOKEN) -> SigV4 route. | |
| 84 | 85 | // 4. AWS_PROFILE -> read ~/.aws/credentials, take that profile's keys. | |
| 86 | + | // 5. AWS_PROFILE -> `aws configure export-credentials` (SSO, assume-role, | |
| 87 | + | // credential_process) when the profile has no static keys. | |
| 85 | 88 | // | |
| 86 | 89 | // region defaults to us-east-1 unless AWS_REGION / AWS_DEFAULT_REGION is | |
| 87 | 90 | // set or the baseURL embeds a region. | |
| 88 | 91 | func NewBedrockClient(apiKey, baseURL string) Client { | |
| 89 | - | region := os.Getenv("AWS_REGION") | |
| 90 | - | if region == "" { | |
| 91 | - | region = os.Getenv("AWS_DEFAULT_REGION") | |
| 92 | - | } | |
| 93 | - | if region == "" { | |
| 94 | - | region = "us-east-1" | |
| 95 | - | } | |
| 92 | + | region := bedrockResolveRegion() | |
| 96 | 93 | if baseURL == "" { | |
| 97 | 94 | baseURL = "https://bedrock-runtime." + region + ".amazonaws.com" | |
| 98 | 95 | } | |
| @@ -102,36 +99,117 @@ func NewBedrockClient(apiKey, baseURL string) Client { | |||
| 102 | 99 | http: &http.Client{Timeout: 0}, | |
| 103 | 100 | } | |
| 104 | 101 | ||
| 105 | - | // Bearer route. | |
| 102 | + | bearer, sigv4 := resolveBedrockAuth(apiKey) | |
| 103 | + | if bearer != "" { | |
| 104 | + | c.bearerToken = bearer | |
| 105 | + | return c | |
| 106 | + | } | |
| 107 | + | if sigv4 != nil { | |
| 108 | + | c.sigv4 = sigv4 | |
| 109 | + | return c | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | return &unimplementedClient{ | |
| 113 | + | name: "amazon-bedrock", | |
| 114 | + | hint: "no Bedrock credentials found (set AWS_BEARER_TOKEN_BEDROCK, AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY, or AWS_PROFILE)", | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // bedrockResolveRegion returns the AWS region for Bedrock calls, | |
| 119 | + | // honoring AWS_REGION then AWS_DEFAULT_REGION, defaulting to us-east-1. | |
| 120 | + | func bedrockResolveRegion() string { | |
| 121 | + | region := os.Getenv("AWS_REGION") | |
| 122 | + | if region == "" { | |
| 123 | + | region = os.Getenv("AWS_DEFAULT_REGION") | |
| 124 | + | } | |
| 125 | + | if region == "" { | |
| 126 | + | region = "us-east-1" | |
| 127 | + | } | |
| 128 | + | return region | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // resolveBedrockAuth determines Bedrock credentials from the same | |
| 132 | + | // sources NewBedrockClient uses, so discovery and inference share one | |
| 133 | + | // resolution path. Exactly one of (bearer, sigv4) is non-empty on | |
| 134 | + | // success; both are zero when no credentials are found. | |
| 135 | + | // | |
| 136 | + | // Resolution order (first match wins): explicit apiKey bearer, then | |
| 137 | + | // AWS_BEARER_TOKEN_BEDROCK, then AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY | |
| 138 | + | // env vars, then ~/.aws/credentials via AWS_PROFILE. | |
| 139 | + | func resolveBedrockAuth(apiKey string) (bearer string, sigv4 *bedrockSigV4Creds) { | |
| 106 | 140 | token := apiKey | |
| 107 | 141 | if token == "" || token == "<aws>" { | |
| 108 | 142 | token = os.Getenv("AWS_BEARER_TOKEN_BEDROCK") | |
| 109 | 143 | } | |
| 110 | 144 | if token != "" && token != "<aws>" { | |
| 111 | - | c.bearerToken = token | |
| 112 | - | return c | |
| 145 | + | return token, nil | |
| 113 | 146 | } | |
| 114 | 147 | ||
| 115 | - | // SigV4 route: env vars first. | |
| 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 | 154 | ak := os.Getenv("AWS_ACCESS_KEY_ID") | |
| 117 | 155 | sk := os.Getenv("AWS_SECRET_ACCESS_KEY") | |
| 118 | 156 | st := os.Getenv("AWS_SESSION_TOKEN") | |
| 119 | 157 | 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} | |
| 122 | 159 | } | |
| 123 | 160 | ||
| 124 | - | // SigV4 route: ~/.aws/credentials via AWS_PROFILE. | |
| 125 | 161 | if profile := os.Getenv("AWS_PROFILE"); profile != "" { | |
| 126 | 162 | if creds, err := readAWSCredentialsFile(profile); err == nil { | |
| 127 | - | c.sigv4 = creds | |
| 128 | - | return c | |
| 163 | + | return creds | |
| 129 | 164 | } | |
| 130 | 165 | } | |
| 131 | 166 | ||
| 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, | |
| 135 | 213 | } | |
| 136 | 214 | } | |
| 137 | 215 | ||
| @@ -50,6 +50,7 @@ import ( | |||||
| 50 | "net/http" | 50 | "net/http" | ||
| 51 | "net/url" | 51 | "net/url" | ||
| 52 | "os" | 52 | "os" | ||
| 53 | + | "os/exec" | |||
| 53 | "sort" | 54 | "sort" | ||
| 54 | "strings" | 55 | "strings" | ||
| 55 | "time" | 56 | "time" | ||
| @@ -82,17 +83,13 @@ type bedrockSigV4Creds struct { | |||||
| 82 | // 3. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ optional | 83 | // 3. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ optional | ||
| 83 | // AWS_SESSION_TOKEN) -> SigV4 route. | 84 | // AWS_SESSION_TOKEN) -> SigV4 route. | ||
| 84 | // 4. AWS_PROFILE -> read ~/.aws/credentials, take that profile's keys. | 85 | // 4. AWS_PROFILE -> read ~/.aws/credentials, take that profile's keys. | ||
| 86 | + | // 5. AWS_PROFILE -> `aws configure export-credentials` (SSO, assume-role, | |||
| 87 | + | // credential_process) when the profile has no static keys. | |||
| 85 | // | 88 | // | ||
| 86 | // region defaults to us-east-1 unless AWS_REGION / AWS_DEFAULT_REGION is | 89 | // region defaults to us-east-1 unless AWS_REGION / AWS_DEFAULT_REGION is | ||
| 87 | // set or the baseURL embeds a region. | 90 | // set or the baseURL embeds a region. | ||
| 88 | func NewBedrockClient(apiKey, baseURL string) Client { | 91 | func NewBedrockClient(apiKey, baseURL string) Client { | ||
| 89 | - | region := os.Getenv("AWS_REGION") | 92 | + | region := bedrockResolveRegion() |
| 90 | - | if region == "" { | |||
| 91 | - | region = os.Getenv("AWS_DEFAULT_REGION") | |||
| 92 | - | } | |||
| 93 | - | if region == "" { | |||
| 94 | - | region = "us-east-1" | |||
| 95 | - | } | |||
| 96 | if baseURL == "" { | 93 | if baseURL == "" { | ||
| 97 | baseURL = "https://bedrock-runtime." + region + ".amazonaws.com" | 94 | baseURL = "https://bedrock-runtime." + region + ".amazonaws.com" | ||
| 98 | } | 95 | } | ||
| @@ -102,36 +99,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 := apiKey | 140 | token := apiKey | ||
| 107 | if token == "" || token == "<aws>" { | 141 | if token == "" || token == "<aws>" { | ||
| 108 | token = os.Getenv("AWS_BEARER_TOKEN_BEDROCK") | 142 | token = os.Getenv("AWS_BEARER_TOKEN_BEDROCK") | ||
| 109 | } | 143 | } | ||
| 110 | if token != "" && token != "<aws>" { | 144 | if token != "" && token != "<aws>" { | ||
| 111 | - | c.bearerToken = token | 145 | + | return token, nil |
| 112 | - | return c | |||
| 113 | } | 146 | } | ||
| 114 | 147 | ||||
| 115 | - | // SigV4 route: env vars first. | 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 = creds | 163 | + | 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{ | |||
| 34 | 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 | 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 | 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 | 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 | 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 | 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 | 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-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"}, | |
| 43 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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"}, | |
| @@ -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 | + | } | |||