Skip to content

Commit 4e8f733

Browse files
Add --fable-bedrock-primary to route Fable to Bedrock first (#65)
When enabled (flag or SUBROUTER_FABLE_BEDROCK_PRIMARY), Claude Fable requests serve from the AWS Bedrock gateway before the subscription pool. A non-2xx or unreachable Bedrock restores the request body and falls through to the normal pool path, which keeps its own Bedrock/API-key fallback, so Fable never hard-fails. Defaults off; only takes effect when the Bedrock gateway is configured. Other Claude models are unaffected.
1 parent f97b376 commit 4e8f733

4 files changed

Lines changed: 152 additions & 19 deletions

File tree

cmd/subrouter/main.go

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,17 @@ func newCLIFileLogHandler(path string) slog.Handler {
7373
return slog.NewTextHandler(file, opts)
7474
}
7575

76+
// envTrue reports whether an environment variable is set to a truthy value
77+
// ("1", "true", "yes", "on", case-insensitive).
78+
func envTrue(name string) bool {
79+
switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) {
80+
case "1", "true", "yes", "on":
81+
return true
82+
default:
83+
return false
84+
}
85+
}
86+
7687
func run(args []string) error {
7788
return runForProgram("subrouter", args)
7889
}
@@ -182,6 +193,7 @@ func serve(args []string) error {
182193
bedrockGatewayToken := flags.String("bedrock-gateway-token", "", "optional bearer token clients must present to the Bedrock gateway; defaults to SUBROUTER_BEDROCK_GATEWAY_TOKEN")
183194
bedrockProfiles := flags.String("bedrock-profiles", "", "comma-separated AWS profiles for the Bedrock gateway; defaults to SUBROUTER_BEDROCK_PROFILES or discovered awN profiles")
184195
bedrockAutoBump := flags.Bool("bedrock-autobump", false, "request a Service Quotas increase (2x, deduped) when Bedrock throttles Fable/Opus")
196+
fableBedrockPrimary := flags.Bool("fable-bedrock-primary", false, "route Claude Fable to Bedrock first (before the subscription pool); defaults to SUBROUTER_FABLE_BEDROCK_PRIMARY")
185197
if err := flags.Parse(args); err != nil {
186198
return err
187199
}
@@ -298,25 +310,26 @@ func serve(args []string) error {
298310
})
299311

300312
server := proxy.Server{
301-
Upstream: upstream,
302-
CodexUpstream: codexUpstream,
303-
APIUpstream: apiUpstream,
304-
ClaudeUpstream: claudeUpstream,
305-
KimiUpstream: kimiUpstream,
306-
ZAIUpstream: zaiUpstream,
307-
Accounts: nil,
308-
AccountRef: accountRef,
309-
Sessions: store,
310-
SchedulerRef: schedulerRef,
311-
UsageScoreTTL: usageScoreTTLForServe(*fetchUsage, *usageScoreTTL),
312-
Transport: outboundTransport,
313-
Logger: slog.Default(),
314-
Lifecycle: proxy.NewLifecycle(),
315-
AdminToken: *adminToken,
316-
MaxBodyBytes: *maxBodyBytes,
317-
Bedrock: bedrockConfig,
318-
ClaudeFableAPIKey: strings.TrimSpace(os.Getenv("SUBROUTER_CLAUDE_FABLE_API_KEY")),
319-
Transcripts: transcript.NewRecorder(*transcriptDir),
313+
Upstream: upstream,
314+
CodexUpstream: codexUpstream,
315+
APIUpstream: apiUpstream,
316+
ClaudeUpstream: claudeUpstream,
317+
KimiUpstream: kimiUpstream,
318+
ZAIUpstream: zaiUpstream,
319+
Accounts: nil,
320+
AccountRef: accountRef,
321+
Sessions: store,
322+
SchedulerRef: schedulerRef,
323+
UsageScoreTTL: usageScoreTTLForServe(*fetchUsage, *usageScoreTTL),
324+
Transport: outboundTransport,
325+
Logger: slog.Default(),
326+
Lifecycle: proxy.NewLifecycle(),
327+
AdminToken: *adminToken,
328+
MaxBodyBytes: *maxBodyBytes,
329+
Bedrock: bedrockConfig,
330+
ClaudeFableAPIKey: strings.TrimSpace(os.Getenv("SUBROUTER_CLAUDE_FABLE_API_KEY")),
331+
FableBedrockPrimary: *fableBedrockPrimary || envTrue("SUBROUTER_FABLE_BEDROCK_PRIMARY"),
332+
Transcripts: transcript.NewRecorder(*transcriptDir),
320333
}
321334
transcriptGCSSyncer := transcript.NewGCSSyncer(transcript.GCSSyncerConfig{
322335
SourceDir: *transcriptDir,

internal/proxy/claude_fable.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,56 @@ func (s Server) claudeFableRequest(r *http.Request) bool {
3636
return claudeFableModel(session.ExtractModel(r, s.MaxBodyBytes))
3737
}
3838

39+
// serveClaudeFableBedrockPrimary serves a Fable request from AWS Bedrock before
40+
// the subscription pool is consulted (FableBedrockPrimary mode). It streams the
41+
// response only on a 2xx from Bedrock; on any non-2xx status or a Bedrock error
42+
// it restores the request body and returns false so the caller continues to the
43+
// normal pool path (which keeps its own Bedrock/API-key fallback). This makes
44+
// Bedrock the primary Fable route without ever hard-failing when Bedrock is
45+
// throttled or unreachable.
46+
func (s Server) serveClaudeFableBedrockPrimary(w http.ResponseWriter, r *http.Request) bool {
47+
body, err := io.ReadAll(io.LimitReader(r.Body, replayablePostMaxBodyBytes))
48+
if err != nil {
49+
return false
50+
}
51+
restore := func() {
52+
r.Body = io.NopCloser(bytes.NewReader(body))
53+
r.ContentLength = int64(len(body))
54+
r.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }
55+
}
56+
resp, err := s.claudeFableBedrockResponse(r.Context(), body)
57+
if err != nil {
58+
if s.Logger != nil && r.Context().Err() == nil {
59+
s.Logger.Warn("fable bedrock-primary request failed, falling through to pool", "error", err)
60+
}
61+
restore()
62+
return false
63+
}
64+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
65+
if s.Logger != nil {
66+
s.Logger.Warn("fable bedrock-primary non-2xx, falling through to pool", "status", resp.StatusCode)
67+
}
68+
_ = resp.Body.Close()
69+
restore()
70+
return false
71+
}
72+
if s.Logger != nil {
73+
s.Logger.Info("serving claude fable via bedrock-primary", "status", resp.StatusCode)
74+
}
75+
defer resp.Body.Close()
76+
for key, values := range resp.Header {
77+
if isHopByHopHeader(key) {
78+
continue
79+
}
80+
for _, value := range values {
81+
w.Header().Add(key, value)
82+
}
83+
}
84+
w.WriteHeader(resp.StatusCode)
85+
flushingCopy(w, resp.Body, nil)
86+
return true
87+
}
88+
3989
// serveClaudeFableFallback serves a Fable request straight off the fallback
4090
// chain (Bedrock, then the dedicated API key). Used when the subscription pool
4191
// cannot even start: no usable Claude OAuth account or selection failed. It

internal/proxy/claude_fable_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,3 +537,57 @@ func TestClaudeFableBedrockStripsUnsupportedFields(t *testing.T) {
537537
t.Fatalf("bedrock body missing anthropic_version: %s", forwarded)
538538
}
539539
}
540+
541+
// bedrockPrimaryServer builds a Server with the Bedrock gateway configured and
542+
// FableBedrockPrimary enabled, whose Bedrock upstream returns the given status
543+
// and body.
544+
func bedrockPrimaryServer(status int, body string) Server {
545+
rt := bedrockRoundTripFunc(func(req *http.Request) (*http.Response, error) {
546+
return &http.Response{
547+
StatusCode: status,
548+
Body: io.NopCloser(strings.NewReader(body)),
549+
Header: http.Header{"Content-Type": []string{"application/json"}},
550+
}, nil
551+
})
552+
return Server{
553+
MaxBodyBytes: 1 << 20,
554+
FableBedrockPrimary: true,
555+
Bedrock: &BedrockConfig{Regions: []string{"us-east-1"}, Credentials: staticBedrockCreds(), Transport: rt},
556+
}
557+
}
558+
559+
func TestServeClaudeFableBedrockPrimarySuccess(t *testing.T) {
560+
s := bedrockPrimaryServer(200, `{"model":"claude-fable-5","content":[{"type":"text","text":"ok"}]}`)
561+
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"claude-fable-5","max_tokens":8,"messages":[]}`))
562+
rec := httptest.NewRecorder()
563+
if !s.serveClaudeFableBedrockPrimary(rec, req) {
564+
t.Fatal("expected bedrock-primary to serve a 2xx Bedrock response")
565+
}
566+
if rec.Code != 200 {
567+
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
568+
}
569+
if !strings.Contains(rec.Body.String(), "claude-fable-5") {
570+
t.Fatalf("body = %q, want forwarded Bedrock body", rec.Body.String())
571+
}
572+
}
573+
574+
func TestServeClaudeFableBedrockPrimaryFallsThroughOnNon2xx(t *testing.T) {
575+
s := bedrockPrimaryServer(429, `{"message":"Too many requests"}`)
576+
bodyStr := `{"model":"claude-fable-5","max_tokens":8,"messages":[]}`
577+
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(bodyStr))
578+
rec := httptest.NewRecorder()
579+
if s.serveClaudeFableBedrockPrimary(rec, req) {
580+
t.Fatal("expected bedrock-primary to fall through on a non-2xx Bedrock response")
581+
}
582+
if rec.Code != 200 { // httptest default; nothing should have been written
583+
t.Fatalf("status = %d, want untouched recorder (nothing written)", rec.Code)
584+
}
585+
// Body must be restored so the pool path can read it.
586+
restored, err := io.ReadAll(req.Body)
587+
if err != nil {
588+
t.Fatalf("read restored body: %v", err)
589+
}
590+
if string(restored) != bodyStr {
591+
t.Fatalf("restored body = %q, want %q", string(restored), bodyStr)
592+
}
593+
}

internal/proxy/proxy.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ type Server struct {
6565
// ONLY to Fable; Opus/Sonnet/etc. continue to use the OAuth pool and never
6666
// touch this key.
6767
ClaudeFableAPIKey string
68+
// FableBedrockPrimary, when true, routes Claude Fable requests to AWS Bedrock
69+
// FIRST, before the subscription pool, instead of using Bedrock only as a
70+
// fallback. It only takes effect when the Bedrock gateway is configured; a
71+
// non-2xx Bedrock response (or an unreachable Bedrock) falls through to the
72+
// normal pool path, which keeps its own Bedrock/API-key fallback. Applies
73+
// ONLY to Fable; other Claude models are unaffected.
74+
FableBedrockPrimary bool
6875
}
6976

7077
type ActiveSessions struct {
@@ -1649,6 +1656,15 @@ func (s Server) proxyHandler() http.Handler {
16491656
fableFallbackConfigured = s.claudeFableEnabled() && claudeFableModel(requestModel) &&
16501657
r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/v1/messages")
16511658
}
1659+
// Bedrock-primary: when enabled, serve Fable straight from Bedrock before
1660+
// touching the subscription pool. A non-2xx or unreachable Bedrock restores
1661+
// the body and falls through to the normal pool path (which still carries
1662+
// its own Bedrock/API-key fallback), so this never hard-fails Fable.
1663+
if s.FableBedrockPrimary && fableFallbackConfigured && s.Bedrock != nil && s.Bedrock.configured() {
1664+
if s.serveClaudeFableBedrockPrimary(w, r) {
1665+
return
1666+
}
1667+
}
16521668
sessionAgentType := agentTypeForProviderSession(agentType, requestProvider)
16531669
account, sessionID, userEmail, err := s.accountForSessionProvider(requestProvider, sessionAgentType, sessionID, r)
16541670
if err != nil {

0 commit comments

Comments
 (0)