From 805f9dae1e20706366cca9eea0c2a26e5a762b7d Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 11 Jun 2026 18:44:42 +0000 Subject: [PATCH 1/2] feat(providers/anthropic): surface refusal stop_reason as content-filter Map Anthropic's "refusal" stop_reason to FinishReasonContentFilter and capture stop_details (category, explanation) into the finish part's ProviderMetadata via a new RefusalMetadata type, so callers can surface why a turn produced no content. Coder Agents generated. --- providers/anthropic/anthropic.go | 48 ++++++++++++++- providers/anthropic/provider_options.go | 53 +++++++++++++++++ providers/anthropic/refusal_test.go | 79 +++++++++++++++++++++++++ 3 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 providers/anthropic/refusal_test.go diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index 66b38a414..7e6773f95 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -1281,11 +1281,49 @@ func mapFinishReason(finishReason string) fantasy.FinishReason { return fantasy.FinishReasonLength case "tool_use": return fantasy.FinishReasonToolCalls + case "refusal": + // Anthropic's real-time safety classifiers stopped the response. + return fantasy.FinishReasonContentFilter default: return fantasy.FinishReasonUnknown } } +// parseAnthropicRefusal extracts stop_details from a message_delta delta JSON +// when the model refused (stop_reason "refusal"). It returns nil when the delta +// carries no refusal details. +func parseAnthropicRefusal(deltaJSON string) *RefusalMetadata { + if deltaJSON == "" { + return nil + } + var parsed struct { + StopDetails *struct { + Type string `json:"type"` + Category string `json:"category"` + Explanation string `json:"explanation"` + } `json:"stop_details"` + } + if err := json.Unmarshal([]byte(deltaJSON), &parsed); err != nil { + return nil + } + if parsed.StopDetails == nil || parsed.StopDetails.Type != "refusal" { + return nil + } + return &RefusalMetadata{ + Category: parsed.StopDetails.Category, + Explanation: parsed.StopDetails.Explanation, + } +} + +// refusalProviderMetadata builds the finish-part provider metadata for a +// refusal, or an empty map when there is none. +func refusalProviderMetadata(refusal *RefusalMetadata) fantasy.ProviderMetadata { + if refusal == nil { + return fantasy.ProviderMetadata{} + } + return fantasy.ProviderMetadata{Name: refusal} +} + // Generate implements fantasy.LanguageModel. func (a languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) { params, rawTools, warnings, betaFlags, err := a.prepareParams(call) @@ -1433,6 +1471,7 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S stream := a.client.Messages.NewStreaming(ctx, *params, reqOpts...) acc := anthropic.Message{} var sawMessageStop bool + var refusalMeta *RefusalMetadata return func(yield func(fantasy.StreamPart) bool) { if len(warnings) > 0 { if !yield(fantasy.StreamPart{ @@ -1649,6 +1688,13 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S return } } + case "message_delta": + // Capture stop_details on a refusal so the finish part can + // carry the reason. The Anthropic SDK does not model + // stop_details, so parse it from the raw delta JSON. + if rm := parseAnthropicRefusal(chunk.AsMessageDelta().Delta.RawJSON()); rm != nil { + refusalMeta = rm + } case "message_stop": sawMessageStop = true } @@ -1677,7 +1723,7 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S CacheCreationTokens: acc.Usage.CacheCreationInputTokens, CacheReadTokens: acc.Usage.CacheReadInputTokens, }, - ProviderMetadata: fantasy.ProviderMetadata{}, + ProviderMetadata: refusalProviderMetadata(refusalMeta), }) return } else { //nolint: revive diff --git a/providers/anthropic/provider_options.go b/providers/anthropic/provider_options.go index ed93005b0..3668b735c 100644 --- a/providers/anthropic/provider_options.go +++ b/providers/anthropic/provider_options.go @@ -43,6 +43,7 @@ const ( TypeReasoningOptionMetadata = Name + ".reasoning_metadata" TypeProviderCacheControl = Name + ".cache_control_options" TypeWebSearchResultMetadata = Name + ".web_search_result_metadata" + TypeRefusalMetadata = Name + ".refusal_metadata" ) // Register Anthropic provider-specific types with the global registry. @@ -75,6 +76,58 @@ func init() { } return &v, nil }) + fantasy.RegisterProviderType(TypeRefusalMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) { + var v RefusalMetadata + if err := json.Unmarshal(data, &v); err != nil { + return nil, err + } + return &v, nil + }) +} + +// RefusalMetadata carries the details Anthropic emits in stop_details when a +// response is stopped by its real-time safety classifiers (stop_reason +// "refusal"). It is attached to the finish stream part so callers can surface +// why a turn produced no content. +type RefusalMetadata struct { + // Category is the refusal category, e.g. "cyber". + Category string `json:"category"` + // Explanation is the human-readable explanation, including any help link. + Explanation string `json:"explanation"` +} + +// Options implements the ProviderOptionsData interface. +func (*RefusalMetadata) Options() {} + +// MarshalJSON implements custom JSON marshaling with type info for RefusalMetadata. +func (m RefusalMetadata) MarshalJSON() ([]byte, error) { + type plain RefusalMetadata + return fantasy.MarshalProviderType(TypeRefusalMetadata, plain(m)) +} + +// UnmarshalJSON implements custom JSON unmarshaling with type info for RefusalMetadata. +func (m *RefusalMetadata) UnmarshalJSON(data []byte) error { + type plain RefusalMetadata + var p plain + if err := fantasy.UnmarshalProviderType(data, &p); err != nil { + return err + } + *m = RefusalMetadata(p) + return nil +} + +// GetRefusalMetadata returns the Anthropic refusal metadata from a provider +// metadata map, or nil when absent. +func GetRefusalMetadata(metadata fantasy.ProviderMetadata) *RefusalMetadata { + if metadata == nil { + return nil + } + if data, ok := metadata[Name]; ok { + if refusal, ok := data.(*RefusalMetadata); ok { + return refusal + } + } + return nil } // ProviderOptions represents additional options for the Anthropic provider. diff --git a/providers/anthropic/refusal_test.go b/providers/anthropic/refusal_test.go new file mode 100644 index 000000000..f25a23ea8 --- /dev/null +++ b/providers/anthropic/refusal_test.go @@ -0,0 +1,79 @@ +package anthropic + +import ( + "testing" + + fantasy "charm.land/fantasy" +) + +func TestMapFinishReasonRefusal(t *testing.T) { + t.Parallel() + + cases := map[string]fantasy.FinishReason{ + "end_turn": fantasy.FinishReasonStop, + "pause_turn": fantasy.FinishReasonStop, + "stop_sequence": fantasy.FinishReasonStop, + "max_tokens": fantasy.FinishReasonLength, + "tool_use": fantasy.FinishReasonToolCalls, + "refusal": fantasy.FinishReasonContentFilter, + "": fantasy.FinishReasonUnknown, + "something_new": fantasy.FinishReasonUnknown, + } + for in, want := range cases { + if got := mapFinishReason(in); got != want { + t.Errorf("mapFinishReason(%q) = %q, want %q", in, got, want) + } + } +} + +func TestParseAnthropicRefusal(t *testing.T) { + t.Parallel() + + t.Run("Refusal", func(t *testing.T) { + t.Parallel() + delta := `{"stop_reason":"refusal","stop_details":{"type":"refusal","category":"cyber","explanation":"blocked under policy"}}` + got := parseAnthropicRefusal(delta) + if got == nil { + t.Fatal("expected refusal metadata, got nil") + } + if got.Category != "cyber" { + t.Errorf("category = %q, want cyber", got.Category) + } + if got.Explanation != "blocked under policy" { + t.Errorf("explanation = %q, want %q", got.Explanation, "blocked under policy") + } + }) + + t.Run("NoStopDetails", func(t *testing.T) { + t.Parallel() + if got := parseAnthropicRefusal(`{"stop_reason":"end_turn"}`); got != nil { + t.Errorf("expected nil, got %+v", got) + } + }) + + t.Run("EmptyAndInvalid", func(t *testing.T) { + t.Parallel() + if got := parseAnthropicRefusal(""); got != nil { + t.Errorf("expected nil for empty, got %+v", got) + } + if got := parseAnthropicRefusal("not json"); got != nil { + t.Errorf("expected nil for invalid, got %+v", got) + } + }) +} + +func TestRefusalMetadataRoundTrip(t *testing.T) { + t.Parallel() + + in := refusalProviderMetadata(&RefusalMetadata{Category: "cyber", Explanation: "blocked"}) + got := GetRefusalMetadata(in) + if got == nil { + t.Fatal("expected refusal metadata, got nil") + } + if got.Category != "cyber" || got.Explanation != "blocked" { + t.Errorf("unexpected metadata: %+v", got) + } + if GetRefusalMetadata(refusalProviderMetadata(nil)) != nil { + t.Error("expected nil metadata for nil refusal") + } +} From 654801e0804b91bdbb3c9dedc4bd270739b0c2b4 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 11 Jun 2026 20:13:13 +0000 Subject: [PATCH 2/2] docs(providers/anthropic): attribute missing stop_details modeling to the pinned SDK fork --- providers/anthropic/anthropic.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index 7e6773f95..38d32053c 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -1690,8 +1690,9 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S } case "message_delta": // Capture stop_details on a refusal so the finish part can - // carry the reason. The Anthropic SDK does not model - // stop_details, so parse it from the raw delta JSON. + // carry the reason. The pinned SDK fork (v1.26.0 base) does + // not model stop_details (upstream added it in v1.29.0), so + // parse it from the raw delta JSON. if rm := parseAnthropicRefusal(chunk.AsMessageDelta().Delta.RawJSON()); rm != nil { refusalMeta = rm }