Skip to content

Commit 76e0e67

Browse files
Merge pull request #55 from manaflow-ai/fix-fable-observability
Fable-chain observability: truthful give-up log, attributed Bedrock gateway traffic (v0.1.33)
2 parents 23e05fb + abfaf7f commit 76e0e67

6 files changed

Lines changed: 160 additions & 6 deletions

File tree

internal/proxy/bedrock.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func (s Server) bedrockHandler() http.Handler {
8282
resp, sourceName, err := s.signAndForwardBedrockWithHeaders(r.Context(), r.Method, upstreamPath, r.URL.RawQuery, headers, body)
8383
if err != nil {
8484
if s.Logger != nil {
85-
s.Logger.Error("bedrock upstream request failed", "path", upstreamPath, "error", err)
85+
s.Logger.Error("bedrock upstream request failed", "path", upstreamPath, "remote_addr", clientRemoteIP(r), "user_agent", r.UserAgent(), "error", err)
8686
}
8787
http.Error(w, "bedrock upstream request failed", http.StatusBadGateway)
8888
return
@@ -102,7 +102,7 @@ func (s Server) bedrockHandler() http.Handler {
102102
model := bedrockModelFromPath(upstreamPath)
103103
if resp.StatusCode == http.StatusTooManyRequests {
104104
if s.Logger != nil {
105-
s.Logger.Warn("bedrock throttled", "model", model, "path", upstreamPath, "bedrock_source", sourceName)
105+
s.Logger.Warn("bedrock throttled", "model", model, "path", upstreamPath, "remote_addr", clientRemoteIP(r), "user_agent", r.UserAgent(), "bedrock_source", sourceName)
106106
}
107107
cfg.onThrottle(sourceName, model)
108108
}

internal/proxy/bedrock_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package proxy
22

33
import (
4+
"bytes"
45
"context"
56
"io"
7+
"log/slog"
68
"net/http"
79
"net/http/httptest"
810
"strings"
@@ -157,6 +159,38 @@ func TestBedrockHandlerRetriesNextSourceOnThrottle(t *testing.T) {
157159
}
158160
}
159161

162+
func TestBedrockHandlerLogsClientAttributionOnThrottle(t *testing.T) {
163+
var logBuf bytes.Buffer
164+
rt := bedrockRoundTripFunc(func(req *http.Request) (*http.Response, error) {
165+
return &http.Response{
166+
StatusCode: http.StatusTooManyRequests,
167+
Body: io.NopCloser(strings.NewReader(`{"error":"throttled"}`)),
168+
Header: http.Header{"Content-Type": []string{"application/json"}},
169+
}, nil
170+
})
171+
s := Server{
172+
Logger: slog.New(slog.NewTextHandler(&logBuf, nil)),
173+
Bedrock: &BedrockConfig{
174+
Region: "us-east-1",
175+
Sources: []BedrockCredentialSource{{Name: "aw0", Credentials: namedBedrockCreds("AKIAONE")}},
176+
Transport: rt,
177+
},
178+
}
179+
180+
req := httptest.NewRequest(http.MethodPost, "/bedrock/model/us.anthropic.claude-fable-5/invoke", strings.NewReader(`{"anthropic_version":"bedrock-2023-05-31"}`))
181+
req.Header.Set("User-Agent", "sr claude-aws")
182+
rec := httptest.NewRecorder()
183+
s.bedrockHandler().ServeHTTP(rec, req)
184+
185+
if rec.Code != http.StatusTooManyRequests {
186+
t.Fatalf("status = %d, want 429 (body %q)", rec.Code, rec.Body.String())
187+
}
188+
logs := logBuf.String()
189+
if !strings.Contains(logs, "bedrock throttled") || !strings.Contains(logs, "remote_addr=") || !strings.Contains(logs, "user_agent=\"sr claude-aws\"") {
190+
t.Fatalf("expected throttle log with remote_addr and user_agent; logs=\n%s", logs)
191+
}
192+
}
193+
160194
func TestBedrockHandlerDisabledWithoutConfig(t *testing.T) {
161195
s := Server{}
162196
rec := httptest.NewRecorder()

internal/proxy/claude_fable_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"errors"
66
"io"
7+
"log/slog"
78
"net/http"
89
"net/http/httptest"
910
"strings"
@@ -364,6 +365,125 @@ func TestUsageLimitRetryTransportClaudeFableFallsBackAfterPoolExhausted(t *testi
364365
}
365366
}
366367

368+
func TestUsageLimitRetryTransportClaudeFableFallbackSuppressesExhaustedLog(t *testing.T) {
369+
server, store := claudeFailoverServer(t)
370+
if _, err := store.Put("claude", "session-fable", "cooked@example.com", ""); err != nil {
371+
t.Fatal(err)
372+
}
373+
stub := &stubRoundTripper{responses: func(req *http.Request) *http.Response {
374+
h := http.Header{}
375+
h.Set("Anthropic-Ratelimit-Unified-Status", "rejected")
376+
return &http.Response{
377+
StatusCode: http.StatusTooManyRequests,
378+
Body: io.NopCloser(strings.NewReader(`{"type":"error","error":{"type":"rate_limit_error","message":"Error"}}`)),
379+
Header: h,
380+
}
381+
}}
382+
bedrockRT := bedrockRoundTripFunc(func(req *http.Request) (*http.Response, error) {
383+
return &http.Response{
384+
StatusCode: http.StatusOK,
385+
Body: io.NopCloser(strings.NewReader(`{"type":"message","usage":{"output_tokens":3}}`)),
386+
Header: http.Header{"Content-Type": []string{"application/json"}},
387+
}, nil
388+
})
389+
server.Bedrock = &BedrockConfig{
390+
Region: "us-east-1",
391+
Sources: []BedrockCredentialSource{{Name: "aw0", Credentials: staticBedrockCreds()}},
392+
Transport: bedrockRT,
393+
}
394+
var logBuf bytes.Buffer
395+
logger := slog.New(slog.NewTextHandler(&logBuf, nil))
396+
fableBody := []byte(`{"model":"claude-fable-5","max_tokens":8,"messages":[]}`)
397+
req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", bytes.NewReader(fableBody))
398+
if err != nil {
399+
t.Fatal(err)
400+
}
401+
req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(fableBody)), nil }
402+
req.Header.Set("Authorization", "Bearer tok-cooked")
403+
transport := usageLimitRetryTransport{
404+
base: stub,
405+
server: &server,
406+
logger: logger,
407+
provider: accounts.ProviderClaude,
408+
agent: "claude",
409+
session: "session-fable",
410+
account: "cooked@example.com",
411+
method: http.MethodPost,
412+
path: "/v1/messages",
413+
maxAttempts: 4,
414+
fableFallback: func() (*http.Response, bool) {
415+
return server.claudeFableFallbackResponse(req, fableBody)
416+
},
417+
}
418+
response, err := transport.RoundTrip(req)
419+
if err != nil {
420+
t.Fatal(err)
421+
}
422+
defer response.Body.Close()
423+
if response.StatusCode != http.StatusOK {
424+
t.Fatalf("status = %d, want 200 from bedrock after pool exhausted", response.StatusCode)
425+
}
426+
logs := logBuf.String()
427+
if !strings.Contains(logs, "serving claude fable via fallback chain") {
428+
t.Fatalf("expected fable fallback save log; logs=\n%s", logs)
429+
}
430+
if strings.Contains(logs, "returned to client after failover exhausted") {
431+
t.Fatalf("did not expect client-facing failover-exhaustion signal on fallback save; logs=\n%s", logs)
432+
}
433+
}
434+
435+
func TestUsageLimitRetryTransportClaudeFableFallbackFailureLogsExhausted(t *testing.T) {
436+
server, store := claudeFailoverServer(t)
437+
if _, err := store.Put("claude", "session-fable", "cooked@example.com", ""); err != nil {
438+
t.Fatal(err)
439+
}
440+
stub := &stubRoundTripper{responses: func(req *http.Request) *http.Response {
441+
h := http.Header{}
442+
h.Set("Anthropic-Ratelimit-Unified-Status", "rejected")
443+
return &http.Response{
444+
StatusCode: http.StatusTooManyRequests,
445+
Body: io.NopCloser(strings.NewReader(`{"type":"error","error":{"type":"rate_limit_error","message":"Error"}}`)),
446+
Header: h,
447+
}
448+
}}
449+
var logBuf bytes.Buffer
450+
logger := slog.New(slog.NewTextHandler(&logBuf, nil))
451+
fableBody := []byte(`{"model":"claude-fable-5","max_tokens":8,"messages":[]}`)
452+
req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", bytes.NewReader(fableBody))
453+
if err != nil {
454+
t.Fatal(err)
455+
}
456+
req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(fableBody)), nil }
457+
req.Header.Set("Authorization", "Bearer tok-cooked")
458+
transport := usageLimitRetryTransport{
459+
base: stub,
460+
server: &server,
461+
logger: logger,
462+
provider: accounts.ProviderClaude,
463+
agent: "claude",
464+
session: "session-fable",
465+
account: "cooked@example.com",
466+
method: http.MethodPost,
467+
path: "/v1/messages",
468+
maxAttempts: 4,
469+
fableFallback: func() (*http.Response, bool) {
470+
return nil, false
471+
},
472+
}
473+
response, err := transport.RoundTrip(req)
474+
if err != nil {
475+
t.Fatal(err)
476+
}
477+
defer response.Body.Close()
478+
if response.StatusCode != http.StatusTooManyRequests {
479+
t.Fatalf("status = %d, want pool 429", response.StatusCode)
480+
}
481+
logs := logBuf.String()
482+
if !strings.Contains(logs, "claude rate-limit returned to client after failover exhausted") {
483+
t.Fatalf("expected client-facing failover-exhaustion signal; logs=\n%s", logs)
484+
}
485+
}
486+
367487
func TestOauthRetryAccountSkipsAPIKeyAccountsWhenOAuthOnly(t *testing.T) {
368488
server, _ := claudeFailoverServer(t)
369489
server.Accounts = append(server.Accounts, accounts.Account{

internal/proxy/proxy.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3404,21 +3404,21 @@ func (t usageLimitRetryTransport) RoundTrip(req *http.Request) (*http.Response,
34043404
if t.server == nil {
34053405
reason = "no_server"
34063406
}
3407-
t.logClaudeFailoverExhausted(response, accountID, reason, attempt, maxAttempts, len(tried))
34083407
if fallback, ok := t.fableFallbackResponse(response, accountID, reason); ok {
34093408
return fallback, nil
34103409
}
3410+
t.logClaudeFailoverExhausted(response, accountID, reason, attempt, maxAttempts, len(tried))
34113411
return response, nil
34123412
}
34133413
nextAccount, pickErr := t.server.oauthRetryAccount(req.Context(), t.provider, t.agent, t.session, t.userEmail, t.poolModel, tried, t.fableFallback != nil)
34143414
if pickErr != nil {
34153415
if t.logger != nil {
34163416
t.logger.Warn("usage-limit retry has no alternate account", "agent", t.agent, "session", t.session, "account", accountID, "method", t.method, "path", t.path, "upstream", t.upstream, "error", pickErr)
34173417
}
3418-
t.logClaudeFailoverExhausted(response, accountID, "no_alternate_account", attempt, maxAttempts, len(tried))
34193418
if fallback, ok := t.fableFallbackResponse(response, accountID, "no_alternate_account"); ok {
34203419
return fallback, nil
34213420
}
3421+
t.logClaudeFailoverExhausted(response, accountID, "no_alternate_account", attempt, maxAttempts, len(tried))
34223422
return response, nil
34233423
}
34243424
body, bodyErr := req.GetBody()

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "subrouter",
3-
"version": "0.1.32",
3+
"version": "0.1.33",
44
"description": "Routes AI coding-agent traffic across subscription accounts and API keys.",
55
"license": "MIT",
66
"homepage": "https://github.com/manaflow-ai/subrouter#readme",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "subrouter"
7-
version = "0.1.32"
7+
version = "0.1.33"
88
description = "Routes AI coding-agent traffic across subscription accounts and API keys."
99
readme = "README.md"
1010
requires-python = ">=3.9"

0 commit comments

Comments
 (0)