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 { | |||
| 213 | 213 | 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 | 261 | // Resolve merges args, config, and env into a Resolved set. | |
| 217 | 262 | // | |
| 218 | 263 | // Unlike the earlier version, Resolve NEVER returns an error for | |
| @@ -223,7 +268,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 223 | 268 | cfg, _ := LoadConfig() | |
| 224 | 269 | ||
| 225 | 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 | 276 | if !isKnownProvider(provName) { | |
| 228 | 277 | // Unknown provider (maybe removed or renamed). Fall back to | |
| 229 | 278 | // the first provider that has credentials, or anthropic. | |
| @@ -213,6 +213,51 @@ func isKnownProvider(name string) bool { | |||||
| 213 | return false | 213 | 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 for | 263 | // 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 to | 277 | // 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) { | |||
| 108 | 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 | + | } | |
| @@ -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, | |||
| 317 | 317 | if resp.StatusCode != http.StatusOK { | |
| 318 | 318 | b, _ := io.ReadAll(resp.Body) | |
| 319 | 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 | 333 | out := make(chan Event, 16) | |
| 323 | 334 | 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) | ||