Skip to content

Commit 8aebef8

Browse files
Bedrock: rotate InvokeModel across multiple regions to raise Fable TPM (#61)
The Fable Bedrock fallback signs every request to a single region (--bedrock-region, default us-east-1), so aggregate throughput is capped at one region's per-account InvokeModel TPM. Under load that region 429s (~86% live), and throttled requests spill to the dedicated Anthropic API key. us.anthropic cross-region inference profiles are invokable from us-east-1/us-east-2/us-west-2, each a separate TPM bucket usable immediately without new AWS accounts or waiting on Service Quotas grants. - --bedrock-region accepts a comma-separated list (trim/dedupe; single value and the us-east-1 default are unchanged). BedrockConfig.Region -> Regions []string with primaryRegion()/regions() helpers. - Rotation walks the regions x sources cross-product (orderedAttempts) with a round-robin start, retrying only on 429/5xx as before. Each attempt signs to its own region: endpoint host and SigV4 scope both use the attempt's region (no cross-region scope mismatch). Single-region config collapses to one attempt = byte-identical to prior behavior. - Throttle/error logs and bedrock-cost.jsonl carry the region (optional field). - Autobump is region-aware: onThrottle(region, model) caches a Service Quotas client per region and dedupes the cooldown by (region, quotaCode); the TPM quota codes are identical across regions. Default stays single-region us-east-1, so behavior is unchanged until an operator opts in with a region list (and enables Claude model access + TPM quota in each listed region; a region lacking access returns a terminal 4xx that is not retried). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e55c9dd commit 8aebef8

8 files changed

Lines changed: 323 additions & 74 deletions

File tree

cmd/subrouter/main.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ func serve(args []string) error {
178178
maxBodyBytes := flags.Int64("max-body-bytes", 1<<20, "max JSON request body bytes to inspect for session IDs")
179179
fetchUsage := flags.Bool("fetch-usage", true, "fetch Codex usage on startup for account selection")
180180
bedrockEnable := flags.Bool("bedrock", false, "enable the /bedrock/* AWS SigV4 signing gateway for Claude Code Bedrock mode")
181-
bedrockRegion := flags.String("bedrock-region", "us-east-1", "AWS region for the Bedrock signing gateway")
181+
bedrockRegion := flags.String("bedrock-region", "us-east-1", "comma-separated AWS regions for the Bedrock signing gateway")
182182
bedrockGatewayToken := flags.String("bedrock-gateway-token", "", "optional bearer token clients must present to the Bedrock gateway; defaults to SUBROUTER_BEDROCK_GATEWAY_TOKEN")
183183
bedrockProfiles := flags.String("bedrock-profiles", "", "comma-separated AWS profiles for the Bedrock gateway; defaults to SUBROUTER_BEDROCK_PROFILES or discovered awN profiles")
184184
bedrockAutoBump := flags.Bool("bedrock-autobump", false, "request a Service Quotas increase (2x, deduped) when Bedrock throttles Fable/Opus")
@@ -265,16 +265,20 @@ func serve(args []string) error {
265265
if token == "" {
266266
token = strings.TrimSpace(os.Getenv("SUBROUTER_BEDROCK_GATEWAY_TOKEN"))
267267
}
268+
regions := parseBedrockRegions(*bedrockRegion)
269+
if len(regions) == 0 {
270+
return errors.New("bedrock: no AWS regions configured")
271+
}
268272
profileNames := bedrockAWSProfileNames(*bedrockProfiles)
269-
awsCfg, sources, err := loadBedrockAWSSources(context.Background(), *bedrockRegion, profileNames, *bedrockAutoBump)
273+
awsCfg, sources, err := loadBedrockAWSSources(context.Background(), regions[0], profileNames, *bedrockAutoBump)
270274
if err != nil {
271275
return fmt.Errorf("bedrock: load AWS config: %w", err)
272276
}
273277
if len(sources) == 0 {
274278
return errors.New("bedrock: no AWS credentials available")
275279
}
276280
bedrockConfig = &proxy.BedrockConfig{
277-
Region: *bedrockRegion,
281+
Regions: regions,
278282
Sources: sources,
279283
GatewayToken: token,
280284
Transport: outboundTransport,
@@ -283,7 +287,7 @@ func serve(args []string) error {
283287
if *bedrockAutoBump {
284288
bedrockConfig.Bumper = proxy.NewBedrockQuotaBumper(awsCfg, slog.Default())
285289
}
286-
slog.Info("bedrock gateway enabled", "region", *bedrockRegion, "auth", token != "", "autobump", *bedrockAutoBump, "profiles", strings.Join(bedrockSourceNames(sources), ","))
290+
slog.Info("bedrock gateway enabled", "regions", strings.Join(regions, ","), "auth", token != "", "autobump", *bedrockAutoBump, "profiles", strings.Join(bedrockSourceNames(sources), ","))
287291
}
288292

289293
initialAccounts := append([]accounts.Account(nil), codexAccounts...)
@@ -414,6 +418,20 @@ func bedrockAWSProfileNames(flagValue string) []string {
414418
return discoverBedrockAWSProfiles(awsSharedConfigPaths())
415419
}
416420

421+
func parseBedrockRegions(raw string) []string {
422+
seen := map[string]bool{}
423+
var out []string
424+
for _, part := range strings.Split(raw, ",") {
425+
region := strings.TrimSpace(part)
426+
if region == "" || seen[region] {
427+
continue
428+
}
429+
seen[region] = true
430+
out = append(out, region)
431+
}
432+
return out
433+
}
434+
417435
func splitProfileList(raw string) []string {
418436
seen := map[string]bool{}
419437
var out []string

cmd/subrouter/sr_claude_aws_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,12 @@ func TestBedrockAWSProfileNames(t *testing.T) {
7171
t.Fatalf("explicit profiles = %q, want aw2,aw1", got)
7272
}
7373
}
74+
75+
func TestParseBedrockRegions(t *testing.T) {
76+
if got := strings.Join(parseBedrockRegions(" us-east-1,us-west-2,, us-east-1,us-east-2 "), ","); got != "us-east-1,us-west-2,us-east-2" {
77+
t.Fatalf("regions = %q, want us-east-1,us-west-2,us-east-2", got)
78+
}
79+
if got := parseBedrockRegions(" , "); len(got) != 0 {
80+
t.Fatalf("empty regions = %v, want none", got)
81+
}
82+
}

internal/proxy/bedrock.go

Lines changed: 78 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ import (
2424
// forwarded to bedrock-runtime, so clients (e.g. Claude Code in Bedrock gateway
2525
// mode with CLAUDE_CODE_SKIP_BEDROCK_AUTH=1) never need AWS credentials.
2626
type BedrockConfig struct {
27-
Region string
27+
// Regions is the ordered set of Bedrock runtime regions to try. List only
28+
// regions where the target inference profile has model access and TPM quota;
29+
// Bedrock 4xx model-access failures are terminal and are not retried.
30+
Regions []string
2831
Credentials aws.CredentialsProvider
2932
Sources []BedrockCredentialSource
3033
// GatewayToken, when non-empty, must be presented by clients via the
@@ -37,8 +40,8 @@ type BedrockConfig struct {
3740
CostLogPath string
3841
// Bumper, when set, requests a Service Quotas increase when Bedrock throttles
3942
// (HTTP 429), deduped per quota with a cooldown.
40-
Bumper *bedrockQuotaBumper
41-
nextSource atomic.Uint64
43+
Bumper *bedrockQuotaBumper
44+
nextAttempt atomic.Uint64
4245
}
4346

4447
type BedrockCredentialSource struct {
@@ -49,6 +52,11 @@ type BedrockCredentialSource struct {
4952

5053
const bedrockService = "bedrock"
5154

55+
type bedrockAttempt struct {
56+
Region string
57+
Source BedrockCredentialSource
58+
}
59+
5260
func (s Server) bedrockHandler() http.Handler {
5361
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
5462
cfg := s.Bedrock
@@ -79,7 +87,7 @@ func (s Server) bedrockHandler() http.Handler {
7987
headers := http.Header{}
8088
copyBedrockRequestHeaders(headers, r.Header)
8189
started := time.Now()
82-
resp, sourceName, err := s.signAndForwardBedrockWithHeaders(r.Context(), r.Method, upstreamPath, r.URL.RawQuery, headers, body)
90+
resp, sourceName, region, err := s.signAndForwardBedrockWithHeaders(r.Context(), r.Method, upstreamPath, r.URL.RawQuery, headers, body)
8391
if err != nil {
8492
if s.Logger != nil {
8593
s.Logger.Error("bedrock upstream request failed", "path", upstreamPath, "remote_addr", clientRemoteIP(r), "user_agent", r.UserAgent(), "error", err)
@@ -102,15 +110,16 @@ func (s Server) bedrockHandler() http.Handler {
102110
model := bedrockModelFromPath(upstreamPath)
103111
if resp.StatusCode == http.StatusTooManyRequests {
104112
if s.Logger != nil {
105-
s.Logger.Warn("bedrock throttled", "model", model, "path", upstreamPath, "remote_addr", clientRemoteIP(r), "user_agent", r.UserAgent(), "bedrock_source", sourceName)
113+
s.Logger.Warn("bedrock throttled", "model", model, "path", upstreamPath, "remote_addr", clientRemoteIP(r), "user_agent", r.UserAgent(), "bedrock_source", sourceName, "region", region)
106114
}
107-
cfg.onThrottle(sourceName, model)
115+
cfg.onThrottle(sourceName, region, model)
108116
}
109117
usage, haveUsage := s.streamBedrockResponse(w, resp)
110118
if cfg.CostLogPath != "" && model != "" {
111119
record := bedrockCostRecord{
112120
Timestamp: started.UTC().Format(time.RFC3339),
113121
Model: model,
122+
Region: region,
114123
Status: resp.StatusCode,
115124
DurationMs: time.Since(started).Milliseconds(),
116125
}
@@ -159,15 +168,15 @@ func (s Server) claudeFableBedrockResponse(ctx context.Context, body []byte) (*h
159168
}
160169
path := "/model/" + bedrockFableModelID + "/" + endpoint
161170
started := time.Now()
162-
resp, sourceName, err := s.signAndForwardBedrock(ctx, http.MethodPost, path, newBody)
171+
resp, sourceName, region, err := s.signAndForwardBedrock(ctx, http.MethodPost, path, newBody)
163172
if err != nil {
164173
return nil, err
165174
}
166175
if resp.StatusCode == http.StatusTooManyRequests {
167176
if s.Logger != nil {
168-
s.Logger.Warn("bedrock throttled", "model", bedrockFableModelID, "path", path, "bedrock_source", sourceName)
177+
s.Logger.Warn("bedrock throttled", "model", bedrockFableModelID, "path", path, "bedrock_source", sourceName, "region", region)
169178
}
170-
cfg.onThrottle(sourceName, bedrockFableModelID)
179+
cfg.onThrottle(sourceName, region, bedrockFableModelID)
171180
}
172181

173182
if stream && resp.StatusCode == http.StatusOK {
@@ -176,7 +185,7 @@ func (s Server) claudeFableBedrockResponse(ctx context.Context, body []byte) (*h
176185
usage, haveUsage := transcodeBedrockToSSE(pw, resp.Body)
177186
_ = resp.Body.Close()
178187
_ = pw.Close()
179-
s.recordClaudeFableBedrockCost(started, http.StatusOK, usage, haveUsage)
188+
s.recordClaudeFableBedrockCost(started, region, http.StatusOK, usage, haveUsage)
180189
}()
181190
return &http.Response{
182191
Status: "200 OK",
@@ -200,10 +209,10 @@ func (s Server) claudeFableBedrockResponse(ctx context.Context, body []byte) (*h
200209
if len(preview) > 512 {
201210
preview = preview[:512]
202211
}
203-
s.Logger.Warn("claude-fable bedrock error response", "status", resp.StatusCode, "bedrock_source", sourceName, "body", string(preview))
212+
s.Logger.Warn("claude-fable bedrock error response", "status", resp.StatusCode, "bedrock_source", sourceName, "region", region, "body", string(preview))
204213
}
205214
usage, haveUsage := parseBedrockInvokeUsage(respBody)
206-
s.recordClaudeFableBedrockCost(started, resp.StatusCode, usage, haveUsage)
215+
s.recordClaudeFableBedrockCost(started, region, resp.StatusCode, usage, haveUsage)
207216
return &http.Response{
208217
Status: resp.Status,
209218
StatusCode: resp.StatusCode,
@@ -216,14 +225,15 @@ func (s Server) claudeFableBedrockResponse(ctx context.Context, body []byte) (*h
216225
}, nil
217226
}
218227

219-
func (s Server) recordClaudeFableBedrockCost(started time.Time, status int, usage bedrockUsage, haveUsage bool) {
228+
func (s Server) recordClaudeFableBedrockCost(started time.Time, region string, status int, usage bedrockUsage, haveUsage bool) {
220229
cfg := s.Bedrock
221230
if cfg == nil || cfg.CostLogPath == "" {
222231
return
223232
}
224233
record := bedrockCostRecord{
225234
Timestamp: started.UTC().Format(time.RFC3339),
226235
Model: bedrockFableModelID,
236+
Region: region,
227237
Status: status,
228238
DurationMs: time.Since(started).Milliseconds(),
229239
}
@@ -236,51 +246,51 @@ func (s Server) recordClaudeFableBedrockCost(started time.Time, status int, usag
236246

237247
// signAndForwardBedrock SigV4-signs a JSON body to bedrock-runtime and returns
238248
// the raw response plus the Bedrock source name that handled it.
239-
func (s Server) signAndForwardBedrock(ctx context.Context, method, upstreamPath string, body []byte) (*http.Response, string, error) {
249+
func (s Server) signAndForwardBedrock(ctx context.Context, method, upstreamPath string, body []byte) (*http.Response, string, string, error) {
240250
headers := http.Header{"Content-Type": []string{"application/json"}}
241251
return s.signAndForwardBedrockWithHeaders(ctx, method, upstreamPath, "", headers, body)
242252
}
243253

244-
func (s Server) signAndForwardBedrockWithHeaders(ctx context.Context, method, upstreamPath, rawQuery string, headers http.Header, body []byte) (*http.Response, string, error) {
254+
func (s Server) signAndForwardBedrockWithHeaders(ctx context.Context, method, upstreamPath, rawQuery string, headers http.Header, body []byte) (*http.Response, string, string, error) {
245255
cfg := s.Bedrock
246-
sources := cfg.orderedSources()
256+
attempts := cfg.orderedAttempts()
247257
var firstErr error
248-
for i, source := range sources {
249-
resp, err := s.signAndForwardBedrockWithSource(ctx, source, method, upstreamPath, rawQuery, headers, body)
258+
for i, attempt := range attempts {
259+
resp, err := s.signAndForwardBedrockWithSource(ctx, attempt.Source, attempt.Region, method, upstreamPath, rawQuery, headers, body)
250260
if err != nil {
251261
if firstErr == nil {
252262
firstErr = err
253263
}
254264
if s.Logger != nil {
255-
s.Logger.Warn("bedrock source failed", "bedrock_source", source.Name, "path", upstreamPath, "error", err)
265+
s.Logger.Warn("bedrock source failed", "bedrock_source", attempt.Source.Name, "region", attempt.Region, "path", upstreamPath, "error", err)
256266
}
257267
continue
258268
}
259269
// Rotate to the next credential source on a throttle (429, per-account
260270
// TPM) or a Bedrock-side 5xx (e.g. 503 "Bedrock is unable to process
261271
// your request"): both are specific to this source's account/endpoint
262272
// and another source may serve the request fine.
263-
if (resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500) && i+1 < len(sources) {
273+
if (resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500) && i+1 < len(attempts) {
264274
if s.Logger != nil {
265-
s.Logger.Warn("bedrock source unusable, retrying next source", "bedrock_source", source.Name, "path", upstreamPath, "status", resp.StatusCode)
275+
s.Logger.Warn("bedrock source unusable, retrying next source", "bedrock_source", attempt.Source.Name, "region", attempt.Region, "path", upstreamPath, "status", resp.StatusCode)
266276
}
267277
if resp.StatusCode == http.StatusTooManyRequests {
268-
cfg.onThrottle(source.Name, bedrockModelFromPath(upstreamPath))
278+
cfg.onThrottle(attempt.Source.Name, attempt.Region, bedrockModelFromPath(upstreamPath))
269279
}
270280
resp.Body.Close()
271281
continue
272282
}
273-
return resp, source.Name, nil
283+
return resp, attempt.Source.Name, attempt.Region, nil
274284
}
275285
if firstErr != nil {
276-
return nil, "", firstErr
286+
return nil, "", "", firstErr
277287
}
278-
return nil, "", io.ErrUnexpectedEOF
288+
return nil, "", "", io.ErrUnexpectedEOF
279289
}
280290

281-
func (s Server) signAndForwardBedrockWithSource(ctx context.Context, source BedrockCredentialSource, method, upstreamPath, rawQuery string, headers http.Header, body []byte) (*http.Response, error) {
291+
func (s Server) signAndForwardBedrockWithSource(ctx context.Context, source BedrockCredentialSource, region, method, upstreamPath, rawQuery string, headers http.Header, body []byte) (*http.Response, error) {
282292
cfg := s.Bedrock
283-
host := "bedrock-runtime." + cfg.Region + ".amazonaws.com"
293+
host := "bedrock-runtime." + region + ".amazonaws.com"
284294
target := &url.URL{Scheme: "https", Host: host, Path: upstreamPath, RawQuery: rawQuery}
285295
outReq, err := http.NewRequestWithContext(ctx, method, target.String(), bytes.NewReader(body))
286296
if err != nil {
@@ -300,7 +310,7 @@ func (s Server) signAndForwardBedrockWithSource(ctx context.Context, source Bedr
300310
if err != nil {
301311
return nil, err
302312
}
303-
if err := v4.NewSigner().SignHTTP(ctx, creds, outReq, sha256Hex(body), bedrockService, cfg.Region, time.Now()); err != nil {
313+
if err := v4.NewSigner().SignHTTP(ctx, creds, outReq, sha256Hex(body), bedrockService, region, time.Now()); err != nil {
304314
return nil, err
305315
}
306316
transport := cfg.Transport
@@ -314,7 +324,26 @@ func (s Server) signAndForwardBedrockWithSource(ctx context.Context, source Bedr
314324
}
315325

316326
func (cfg *BedrockConfig) configured() bool {
317-
return strings.TrimSpace(cfg.Region) != "" && len(cfg.sources()) > 0
327+
return len(cfg.regions()) > 0 && len(cfg.sources()) > 0
328+
}
329+
330+
func (cfg *BedrockConfig) primaryRegion() string {
331+
if len(cfg.Regions) == 0 {
332+
return ""
333+
}
334+
return cfg.Regions[0]
335+
}
336+
337+
func (cfg *BedrockConfig) regions() []string {
338+
var out []string
339+
for _, region := range cfg.Regions {
340+
region = strings.TrimSpace(region)
341+
if region == "" {
342+
continue
343+
}
344+
out = append(out, region)
345+
}
346+
return out
318347
}
319348

320349
func (cfg *BedrockConfig) sources() []BedrockCredentialSource {
@@ -335,30 +364,40 @@ func (cfg *BedrockConfig) sources() []BedrockCredentialSource {
335364
return out
336365
}
337366

338-
func (cfg *BedrockConfig) orderedSources() []BedrockCredentialSource {
367+
func (cfg *BedrockConfig) orderedAttempts() []bedrockAttempt {
368+
regions := cfg.regions()
339369
sources := cfg.sources()
340-
if len(sources) <= 1 {
341-
return sources
370+
if len(regions) == 0 || len(sources) == 0 {
371+
return nil
372+
}
373+
attempts := make([]bedrockAttempt, 0, len(regions)*len(sources))
374+
for _, region := range regions {
375+
for _, source := range sources {
376+
attempts = append(attempts, bedrockAttempt{Region: region, Source: source})
377+
}
378+
}
379+
if len(attempts) <= 1 {
380+
return attempts
342381
}
343-
start := int(cfg.nextSource.Add(1)-1) % len(sources)
344-
ordered := make([]BedrockCredentialSource, 0, len(sources))
345-
ordered = append(ordered, sources[start:]...)
346-
ordered = append(ordered, sources[:start]...)
382+
start := int(cfg.nextAttempt.Add(1)-1) % len(attempts)
383+
ordered := make([]bedrockAttempt, 0, len(attempts))
384+
ordered = append(ordered, attempts[start:]...)
385+
ordered = append(ordered, attempts[:start]...)
347386
return ordered
348387
}
349388

350-
func (cfg *BedrockConfig) onThrottle(sourceName, model string) {
389+
func (cfg *BedrockConfig) onThrottle(sourceName, region, model string) {
351390
if model == "" {
352391
return
353392
}
354393
for _, source := range cfg.Sources {
355394
if source.Name == sourceName && source.Bumper != nil {
356-
go source.Bumper.onThrottle(model)
395+
go source.Bumper.onThrottle(region, model)
357396
return
358397
}
359398
}
360399
if cfg.Bumper != nil {
361-
go cfg.Bumper.onThrottle(model)
400+
go cfg.Bumper.onThrottle(region, model)
362401
}
363402
}
364403

0 commit comments

Comments
 (0)