From c0890b50d508850ad1ca3d98fb5b09d836cf14c5 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Mon, 25 May 2026 18:41:07 -0500 Subject: [PATCH 1/2] Normalize Anthropic system messages --- autorouter.go | 75 ++++++++++++++++++++++++++++++ autorouter_test.go | 113 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) diff --git a/autorouter.go b/autorouter.go index b17395a..7ea990a 100644 --- a/autorouter.go +++ b/autorouter.go @@ -1001,12 +1001,87 @@ func normalizeProviderRequest(raw map[string]any, providerName string) { const defaultAnthropicMaxTokens = 1024 func normalizeAnthropicRequest(raw map[string]any) { + normalizeAnthropicSystemMessages(raw) + if hasPositiveNumber(raw["max_tokens"]) { return } raw["max_tokens"] = defaultAnthropicMaxTokens } +func normalizeAnthropicSystemMessages(raw map[string]any) { + messages, ok := raw["messages"].([]any) + if !ok || len(messages) == 0 { + return + } + + filtered := make([]any, 0, len(messages)) + systemParts := make([]any, 0, 1) + for _, item := range messages { + message, ok := item.(map[string]any) + if !ok { + filtered = append(filtered, item) + continue + } + role, _ := message["role"].(string) + if role != "system" { + filtered = append(filtered, item) + continue + } + if content, exists := message["content"]; exists { + systemParts = append(systemParts, content) + } + } + + if len(systemParts) == 0 { + return + } + + raw["messages"] = filtered + raw["system"] = mergeAnthropicSystem(raw["system"], systemParts) +} + +func mergeAnthropicSystem(existing any, systemParts []any) any { + systemText := joinTextParts(systemParts) + if existing == nil { + if systemText != "" { + return systemText + } + return systemParts[0] + } + + existingText := joinTextParts([]any{existing}) + if existingText != "" && systemText != "" { + return existingText + "\n\n" + systemText + } + return existing +} + +func joinTextParts(parts []any) string { + values := make([]string, 0, len(parts)) + for _, part := range parts { + switch v := part.(type) { + case string: + if strings.TrimSpace(v) != "" { + values = append(values, v) + } + case []any: + for _, item := range v { + block, ok := item.(map[string]any) + if !ok { + continue + } + blockType, _ := block["type"].(string) + text, _ := block["text"].(string) + if blockType == "text" && strings.TrimSpace(text) != "" { + values = append(values, text) + } + } + } + } + return strings.Join(values, "\n\n") +} + func hasPositiveNumber(value any) bool { switch v := value.(type) { case int: diff --git a/autorouter_test.go b/autorouter_test.go index f32b668..42a750e 100644 --- a/autorouter_test.go +++ b/autorouter_test.go @@ -1506,6 +1506,119 @@ func TestAutoRouter_AnthropicPreservesMaxTokens(t *testing.T) { } } +func TestAutoRouter_AnthropicMovesSystemMessageToTopLevel(t *testing.T) { + var receivedBody map[string]any + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &receivedBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"msg_test","type":"message","model":"claude-3-opus","content":[{"type":"text","text":"Hello"}],"usage":{"input_tokens":8,"output_tokens":1}}`)) + })) + defer upstream.Close() + + provider := &mockProvider{ + name: "anthropic", + parseFn: func(body io.ReadCloser) (BodyMetadata, []byte, error) { + data, _ := io.ReadAll(body) + return BodyMetadata{Model: "claude-3-opus"}, data, nil + }, + enrichFn: func(req *http.Request, meta BodyMetadata, body []byte) error { return nil }, + resolveFn: func(meta BodyMetadata) (*url.URL, error) { + return url.Parse(upstream.URL) + }, + extractFn: func(resp *http.Response) (ResponseMetadata, []byte, error) { + body, _ := io.ReadAll(resp.Body) + return ResponseMetadata{ID: "msg_test"}, body, nil + }, + } + + router := NewAutoRouter( + WithAutoRouterDetector(ProviderDetectorFunc(func(hint ProviderHint) string { return "anthropic" })), + ) + router.RegisterProvider(provider) + + req := httptest.NewRequestWithContext(context.Background(), "POST", "/", bytes.NewReader([]byte(`{"model":"claude-3-opus","messages":[{"role":"system","content":"You are terse."},{"role":"user","content":"Hello"}]}`))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("StatusCode = %d, want 200", w.Code) + } + if got := receivedBody["system"]; got != "You are terse." { + t.Fatalf("system = %v, want %q", got, "You are terse.") + } + messages, ok := receivedBody["messages"].([]any) + if !ok { + t.Fatalf("messages = %T, want []any", receivedBody["messages"]) + } + if len(messages) != 1 { + t.Fatalf("len(messages) = %d, want 1", len(messages)) + } + message, ok := messages[0].(map[string]any) + if !ok { + t.Fatalf("messages[0] = %T, want map[string]any", messages[0]) + } + if got := message["role"]; got != "user" { + t.Fatalf("messages[0].role = %v, want user", got) + } + if got := receivedBody["max_tokens"]; got != float64(defaultAnthropicMaxTokens) { + t.Fatalf("max_tokens = %v, want %d", got, defaultAnthropicMaxTokens) + } +} + +func TestAutoRouter_AnthropicMergesSystemMessageWithExistingSystem(t *testing.T) { + var receivedBody map[string]any + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &receivedBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"msg_test","type":"message","model":"claude-3-opus","content":[{"type":"text","text":"Hello"}],"usage":{"input_tokens":8,"output_tokens":1}}`)) + })) + defer upstream.Close() + + provider := &mockProvider{ + name: "anthropic", + parseFn: func(body io.ReadCloser) (BodyMetadata, []byte, error) { + data, _ := io.ReadAll(body) + return BodyMetadata{Model: "claude-3-opus"}, data, nil + }, + enrichFn: func(req *http.Request, meta BodyMetadata, body []byte) error { return nil }, + resolveFn: func(meta BodyMetadata) (*url.URL, error) { + return url.Parse(upstream.URL) + }, + extractFn: func(resp *http.Response) (ResponseMetadata, []byte, error) { + body, _ := io.ReadAll(resp.Body) + return ResponseMetadata{ID: "msg_test"}, body, nil + }, + } + + router := NewAutoRouter( + WithAutoRouterDetector(ProviderDetectorFunc(func(hint ProviderHint) string { return "anthropic" })), + ) + router.RegisterProvider(provider) + + req := httptest.NewRequestWithContext(context.Background(), "POST", "/", bytes.NewReader([]byte(`{"model":"claude-3-opus","system":"Existing system.","messages":[{"role":"system","content":"Additional system."},{"role":"user","content":"Hello"}]}`))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("StatusCode = %d, want 200", w.Code) + } + if got := receivedBody["system"]; got != "Existing system.\n\nAdditional system." { + t.Fatalf("system = %v, want merged system", got) + } + messages, ok := receivedBody["messages"].([]any) + if !ok || len(messages) != 1 { + t.Fatalf("messages = %#v, want one non-system message", receivedBody["messages"]) + } +} + func TestAutoRouter_StreamingWritesGatewayMetadataEvent(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") From aedd78442ec85d46e1b55e5fb6d142c76f999ed0 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Mon, 25 May 2026 19:07:20 -0500 Subject: [PATCH 2/2] Fix Anthropic system message edge cases --- autorouter.go | 11 +++-- autorouter_test.go | 117 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/autorouter.go b/autorouter.go index 7ea990a..afa08e7 100644 --- a/autorouter.go +++ b/autorouter.go @@ -1033,12 +1033,10 @@ func normalizeAnthropicSystemMessages(raw map[string]any) { } } - if len(systemParts) == 0 { - return - } - raw["messages"] = filtered - raw["system"] = mergeAnthropicSystem(raw["system"], systemParts) + if len(systemParts) > 0 { + raw["system"] = mergeAnthropicSystem(raw["system"], systemParts) + } } func mergeAnthropicSystem(existing any, systemParts []any) any { @@ -1054,6 +1052,9 @@ func mergeAnthropicSystem(existing any, systemParts []any) any { if existingText != "" && systemText != "" { return existingText + "\n\n" + systemText } + if systemText != "" { + return systemText + } return existing } diff --git a/autorouter_test.go b/autorouter_test.go index 42a750e..2645986 100644 --- a/autorouter_test.go +++ b/autorouter_test.go @@ -1619,6 +1619,123 @@ func TestAutoRouter_AnthropicMergesSystemMessageWithExistingSystem(t *testing.T) } } +func TestAutoRouter_AnthropicUsesSystemMessageWhenExistingSystemEmpty(t *testing.T) { + var receivedBody map[string]any + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &receivedBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"msg_test","type":"message","model":"claude-3-opus","content":[{"type":"text","text":"Hello"}],"usage":{"input_tokens":8,"output_tokens":1}}`)) + })) + defer upstream.Close() + + provider := &mockProvider{ + name: "anthropic", + parseFn: func(body io.ReadCloser) (BodyMetadata, []byte, error) { + data, _ := io.ReadAll(body) + return BodyMetadata{Model: "claude-3-opus"}, data, nil + }, + enrichFn: func(req *http.Request, meta BodyMetadata, body []byte) error { return nil }, + resolveFn: func(meta BodyMetadata) (*url.URL, error) { + return url.Parse(upstream.URL) + }, + extractFn: func(resp *http.Response) (ResponseMetadata, []byte, error) { + body, _ := io.ReadAll(resp.Body) + return ResponseMetadata{ID: "msg_test"}, body, nil + }, + } + + router := NewAutoRouter( + WithAutoRouterDetector(ProviderDetectorFunc(func(hint ProviderHint) string { return "anthropic" })), + ) + router.RegisterProvider(provider) + + req := httptest.NewRequestWithContext(context.Background(), "POST", "/", bytes.NewReader([]byte(`{"model":"claude-3-opus","system":"","messages":[{"role":"system","content":"Use terse answers."},{"role":"user","content":"Hello"}]}`))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("StatusCode = %d, want 200", w.Code) + } + if got := receivedBody["system"]; got != "Use terse answers." { + t.Fatalf("system = %v, want %q", got, "Use terse answers.") + } + messages, ok := receivedBody["messages"].([]any) + if !ok || len(messages) != 1 { + t.Fatalf("messages = %#v, want one non-system message", receivedBody["messages"]) + } + message, ok := messages[0].(map[string]any) + if !ok { + t.Fatalf("messages[0] = %T, want map[string]any", messages[0]) + } + if got := message["role"]; got != "user" { + t.Fatalf("messages[0].role = %v, want user", got) + } + if got := receivedBody["max_tokens"]; got != float64(defaultAnthropicMaxTokens) { + t.Fatalf("max_tokens = %v, want %d", got, defaultAnthropicMaxTokens) + } +} + +func TestAutoRouter_AnthropicRemovesSystemMessageWithMissingContent(t *testing.T) { + var receivedBody map[string]any + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &receivedBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"msg_test","type":"message","model":"claude-3-opus","content":[{"type":"text","text":"Hello"}],"usage":{"input_tokens":8,"output_tokens":1}}`)) + })) + defer upstream.Close() + + provider := &mockProvider{ + name: "anthropic", + parseFn: func(body io.ReadCloser) (BodyMetadata, []byte, error) { + data, _ := io.ReadAll(body) + return BodyMetadata{Model: "claude-3-opus"}, data, nil + }, + enrichFn: func(req *http.Request, meta BodyMetadata, body []byte) error { return nil }, + resolveFn: func(meta BodyMetadata) (*url.URL, error) { + return url.Parse(upstream.URL) + }, + extractFn: func(resp *http.Response) (ResponseMetadata, []byte, error) { + body, _ := io.ReadAll(resp.Body) + return ResponseMetadata{ID: "msg_test"}, body, nil + }, + } + + router := NewAutoRouter( + WithAutoRouterDetector(ProviderDetectorFunc(func(hint ProviderHint) string { return "anthropic" })), + ) + router.RegisterProvider(provider) + + req := httptest.NewRequestWithContext(context.Background(), "POST", "/", bytes.NewReader([]byte(`{"model":"claude-3-opus","system":"Existing system.","messages":[{"role":"system"},{"role":"system","content":null},{"role":"user","content":"Hello"}]}`))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("StatusCode = %d, want 200", w.Code) + } + if got := receivedBody["system"]; got != "Existing system." { + t.Fatalf("system = %v, want existing system unchanged", got) + } + messages, ok := receivedBody["messages"].([]any) + if !ok || len(messages) != 1 { + t.Fatalf("messages = %#v, want one non-system message", receivedBody["messages"]) + } + message, ok := messages[0].(map[string]any) + if !ok { + t.Fatalf("messages[0] = %T, want map[string]any", messages[0]) + } + if got := message["role"]; got != "user" { + t.Fatalf("messages[0].role = %v, want user", got) + } +} + func TestAutoRouter_StreamingWritesGatewayMetadataEvent(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream")