detached ec5eb20

feat(provider): alias common provider names and clarify Bedrock 403

Map short/alternate provider names (bedrock -> amazon-bedrock, vertex,
gemini, azure, copilot, codex, ...) to their canonical ids in Resolve so
an alias is never treated as unknown and silently downgraded to
anthropic. Add a region-aware hint to Bedrock 403 responses on the
bearer route.
commit
ec5eb20ce955fb7d80188e3ff8c1285cbb652c13
author
patriceckhart · · 2026-06-03 16:13 UTC
parent
ea58887
signature
unsigned
refs
tag: v0.2.8
3 files · +96 -2
M packages/agent/build.go +50 -1
@@ -213,6 +213,51 @@ func isKnownProvider(name string) bool {
213213 return false
214214 }
215215
216+// providerAliases maps common short / alternate provider names to the
217+// canonical id in knownProviders. Users (and other agents) reach for
218+// "bedrock" or "vertex" far more naturally than the fully-qualified
219+// "amazon-bedrock" / "google-vertex"; without this mapping an alias is
220+// treated as an unknown provider and Resolve silently falls back to
221+// anthropic, producing a misleading "no credential for anthropic" error
222+// after the user explicitly picked, say, bedrock.
223+var providerAliases = map[string]string{
224+ "bedrock": "amazon-bedrock",
225+ "aws-bedrock": "amazon-bedrock",
226+ "amazon": "amazon-bedrock",
227+ "vertex": "google-vertex",
228+ "gcp-vertex": "google-vertex",
229+ "gemini": "google",
230+ "googleai": "google",
231+ "google-ai": "google",
232+ "azure": "azure-openai-responses",
233+ "azure-openai": "azure-openai-responses",
234+ "copilot": "github-copilot",
235+ "github": "github-copilot",
236+ "codex": "openai-codex",
237+ "moonshot": "moonshotai",
238+ "kimi-code": "kimi",
239+ "ai-gateway": "vercel-ai-gateway",
240+ "vercel": "vercel-ai-gateway",
241+ "cloudflare": "cloudflare-workers-ai",
242+ "workers-ai": "cloudflare-workers-ai",
243+ "hf": "huggingface",
244+}
245+
246+// canonicalProvider normalises a user-supplied provider name: trims
247+// surrounding whitespace, lower-cases it, and resolves any known alias
248+// to its canonical id. Unknown names are returned trimmed/lower-cased
249+// and unchanged so the existing unknown-provider handling still runs.
250+func canonicalProvider(name string) string {
251+ n := strings.ToLower(strings.TrimSpace(name))
252+ if n == "" {
253+ return n
254+ }
255+ if canon, ok := providerAliases[n]; ok {
256+ return canon
257+ }
258+ return n
259+}
260+
216261 // Resolve merges args, config, and env into a Resolved set.
217262 //
218263 // Unlike the earlier version, Resolve NEVER returns an error for
@@ -223,7 +268,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
223268 cfg, _ := LoadConfig()
224269
225270 // User-requested provider (explicit > config > default).
226- provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
271+ // Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
272+ // before validation so an alias is never mistaken for an unknown
273+ // provider and silently downgraded to anthropic.
274+ argProvider := canonicalProvider(args.Provider)
275+ provName := firstNonEmpty(argProvider, canonicalProvider(cfg.Provider), "anthropic")
227276 if !isKnownProvider(provName) {
228277 // Unknown provider (maybe removed or renamed). Fall back to
229278 // the first provider that has credentials, or anthropic.
@@ -213,6 +213,51 @@ func isKnownProvider(name string) bool {
213 return false213 return false
214 }214 }
215 215
216+// providerAliases maps common short / alternate provider names to the
217+// canonical id in knownProviders. Users (and other agents) reach for
218+// "bedrock" or "vertex" far more naturally than the fully-qualified
219+// "amazon-bedrock" / "google-vertex"; without this mapping an alias is
220+// treated as an unknown provider and Resolve silently falls back to
221+// anthropic, producing a misleading "no credential for anthropic" error
222+// after the user explicitly picked, say, bedrock.
223+var providerAliases = map[string]string{
224+ "bedrock": "amazon-bedrock",
225+ "aws-bedrock": "amazon-bedrock",
226+ "amazon": "amazon-bedrock",
227+ "vertex": "google-vertex",
228+ "gcp-vertex": "google-vertex",
229+ "gemini": "google",
230+ "googleai": "google",
231+ "google-ai": "google",
232+ "azure": "azure-openai-responses",
233+ "azure-openai": "azure-openai-responses",
234+ "copilot": "github-copilot",
235+ "github": "github-copilot",
236+ "codex": "openai-codex",
237+ "moonshot": "moonshotai",
238+ "kimi-code": "kimi",
239+ "ai-gateway": "vercel-ai-gateway",
240+ "vercel": "vercel-ai-gateway",
241+ "cloudflare": "cloudflare-workers-ai",
242+ "workers-ai": "cloudflare-workers-ai",
243+ "hf": "huggingface",
244+}
245+
246+// canonicalProvider normalises a user-supplied provider name: trims
247+// surrounding whitespace, lower-cases it, and resolves any known alias
248+// to its canonical id. Unknown names are returned trimmed/lower-cased
249+// and unchanged so the existing unknown-provider handling still runs.
250+func canonicalProvider(name string) string {
251+ n := strings.ToLower(strings.TrimSpace(name))
252+ if n == "" {
253+ return n
254+ }
255+ if canon, ok := providerAliases[n]; ok {
256+ return canon
257+ }
258+ return n
259+}
260+
216 // Resolve merges args, config, and env into a Resolved set.261 // Resolve merges args, config, and env into a Resolved set.
217 //262 //
218 // Unlike the earlier version, Resolve NEVER returns an error for263 // Unlike the earlier version, Resolve NEVER returns an error for
@@ -223,7 +268,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
223 cfg, _ := LoadConfig()268 cfg, _ := LoadConfig()
224 269
225 // User-requested provider (explicit > config > default).270 // User-requested provider (explicit > config > default).
226- provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")271+ // Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
272+ // before validation so an alias is never mistaken for an unknown
273+ // provider and silently downgraded to anthropic.
274+ argProvider := canonicalProvider(args.Provider)
275+ provName := firstNonEmpty(argProvider, canonicalProvider(cfg.Provider), "anthropic")
227 if !isKnownProvider(provName) {276 if !isKnownProvider(provName) {
228 // Unknown provider (maybe removed or renamed). Fall back to277 // Unknown provider (maybe removed or renamed). Fall back to
229 // the first provider that has credentials, or anthropic.278 // the first provider that has credentials, or anthropic.
M packages/agent/build_test.go +34 -0
@@ -108,3 +108,37 @@ func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
108108 t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
109109 }
110110 }
111+
112+func TestCanonicalProviderResolvesAliases(t *testing.T) {
113+ cases := map[string]string{
114+ "bedrock": "amazon-bedrock",
115+ "AWS-Bedrock": "amazon-bedrock",
116+ " bedrock ": "amazon-bedrock",
117+ "vertex": "google-vertex",
118+ "gemini": "google",
119+ "azure": "azure-openai-responses",
120+ "copilot": "github-copilot",
121+ "codex": "openai-codex",
122+ "moonshot": "moonshotai",
123+ "vercel": "vercel-ai-gateway",
124+ "hf": "huggingface",
125+ "anthropic": "anthropic", // canonical passes through
126+ "amazon-bedrock": "amazon-bedrock", // already canonical
127+ "totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
128+ "Totally-UNKNOWN": "totally-unknown",
129+ "": "",
130+ }
131+ for in, want := range cases {
132+ if got := canonicalProvider(in); got != want {
133+ t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
134+ }
135+ }
136+}
137+
138+func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
139+ for alias, canon := range providerAliases {
140+ if !isKnownProvider(canon) {
141+ t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
142+ }
143+ }
144+}
@@ -108,3 +108,37 @@ func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
108 t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)108 t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
109 }109 }
110 }110 }
111+
112+func TestCanonicalProviderResolvesAliases(t *testing.T) {
113+ cases := map[string]string{
114+ "bedrock": "amazon-bedrock",
115+ "AWS-Bedrock": "amazon-bedrock",
116+ " bedrock ": "amazon-bedrock",
117+ "vertex": "google-vertex",
118+ "gemini": "google",
119+ "azure": "azure-openai-responses",
120+ "copilot": "github-copilot",
121+ "codex": "openai-codex",
122+ "moonshot": "moonshotai",
123+ "vercel": "vercel-ai-gateway",
124+ "hf": "huggingface",
125+ "anthropic": "anthropic", // canonical passes through
126+ "amazon-bedrock": "amazon-bedrock", // already canonical
127+ "totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
128+ "Totally-UNKNOWN": "totally-unknown",
129+ "": "",
130+ }
131+ for in, want := range cases {
132+ if got := canonicalProvider(in); got != want {
133+ t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
134+ }
135+ }
136+}
137+
138+func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
139+ for alias, canon := range providerAliases {
140+ if !isKnownProvider(canon) {
141+ t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
142+ }
143+ }
144+}
M packages/provider/amazon_bedrock.go +12 -1
@@ -317,7 +317,18 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event,
317317 if resp.StatusCode != http.StatusOK {
318318 b, _ := io.ReadAll(resp.Body)
319319 resp.Body.Close()
320- return nil, fmt.Errorf("bedrock: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
320+ msg := strings.TrimSpace(string(b))
321+ // A 403 on the bearer route is almost always a region mismatch:
322+ // short-term Bedrock API keys are scoped to the region of the
323+ // console session that minted them, but zot defaults to
324+ // us-east-1. Surface the resolved region and the fix so the user
325+ // is not left guessing why a freshly-copied key is "invalid".
326+ if resp.StatusCode == http.StatusForbidden && c.bearerToken != "" {
327+ return nil, fmt.Errorf(
328+ "bedrock: http 403 (region=%s): %s\nhint: Bedrock API keys are region-scoped. If your key was created in another region, set AWS_REGION (e.g. AWS_REGION=eu-central-1) or pass --base-url https://bedrock-runtime.<region>.amazonaws.com",
329+ c.region, msg)
330+ }
331+ return nil, fmt.Errorf("bedrock: http %d: %s", resp.StatusCode, msg)
321332 }
322333 out := make(chan Event, 16)
323334 go c.runStream(ctx, resp, req, out)
@@ -317,7 +317,18 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event,
317 if resp.StatusCode != http.StatusOK {317 if resp.StatusCode != http.StatusOK {
318 b, _ := io.ReadAll(resp.Body)318 b, _ := io.ReadAll(resp.Body)
319 resp.Body.Close()319 resp.Body.Close()
320- return nil, fmt.Errorf("bedrock: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))320+ msg := strings.TrimSpace(string(b))
321+ // A 403 on the bearer route is almost always a region mismatch:
322+ // short-term Bedrock API keys are scoped to the region of the
323+ // console session that minted them, but zot defaults to
324+ // us-east-1. Surface the resolved region and the fix so the user
325+ // is not left guessing why a freshly-copied key is "invalid".
326+ if resp.StatusCode == http.StatusForbidden && c.bearerToken != "" {
327+ return nil, fmt.Errorf(
328+ "bedrock: http 403 (region=%s): %s\nhint: Bedrock API keys are region-scoped. If your key was created in another region, set AWS_REGION (e.g. AWS_REGION=eu-central-1) or pass --base-url https://bedrock-runtime.<region>.amazonaws.com",
329+ c.region, msg)
330+ }
331+ return nil, fmt.Errorf("bedrock: http %d: %s", resp.StatusCode, msg)
321 }332 }
322 out := make(chan Event, 16)333 out := make(chan Event, 16)
323 go c.runStream(ctx, resp, req, out)334 go c.runStream(ctx, resp, req, out)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close