Skip to content

Commit 798e38d

Browse files
jscottmillerethanndickson
authored andcommitted
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.
1 parent 2e3ddbc commit 798e38d

3 files changed

Lines changed: 180 additions & 1 deletion

File tree

‎providers/anthropic/anthropic.go‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1335,11 +1335,49 @@ func mapFinishReason(finishReason string) fantasy.FinishReason {
13351335
return fantasy.FinishReasonLength
13361336
case "tool_use":
13371337
return fantasy.FinishReasonToolCalls
1338+
case "refusal":
1339+
// Anthropic's real-time safety classifiers stopped the response.
1340+
return fantasy.FinishReasonContentFilter
13381341
default:
13391342
return fantasy.FinishReasonUnknown
13401343
}
13411344
}
13421345

1346+
// parseAnthropicRefusal extracts stop_details from a message_delta delta JSON
1347+
// when the model refused (stop_reason "refusal"). It returns nil when the delta
1348+
// carries no refusal details.
1349+
func parseAnthropicRefusal(deltaJSON string) *RefusalMetadata {
1350+
if deltaJSON == "" {
1351+
return nil
1352+
}
1353+
var parsed struct {
1354+
StopDetails *struct {
1355+
Type string `json:"type"`
1356+
Category string `json:"category"`
1357+
Explanation string `json:"explanation"`
1358+
} `json:"stop_details"`
1359+
}
1360+
if err := json.Unmarshal([]byte(deltaJSON), &parsed); err != nil {
1361+
return nil
1362+
}
1363+
if parsed.StopDetails == nil || parsed.StopDetails.Type != "refusal" {
1364+
return nil
1365+
}
1366+
return &RefusalMetadata{
1367+
Category: parsed.StopDetails.Category,
1368+
Explanation: parsed.StopDetails.Explanation,
1369+
}
1370+
}
1371+
1372+
// refusalProviderMetadata builds the finish-part provider metadata for a
1373+
// refusal, or an empty map when there is none.
1374+
func refusalProviderMetadata(refusal *RefusalMetadata) fantasy.ProviderMetadata {
1375+
if refusal == nil {
1376+
return fantasy.ProviderMetadata{}
1377+
}
1378+
return fantasy.ProviderMetadata{Name: refusal}
1379+
}
1380+
13431381
// Generate implements fantasy.LanguageModel.
13441382
func (a languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
13451383
params, rawTools, warnings, betaFlags, err := a.prepareParams(call)
@@ -1500,6 +1538,7 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S
15001538
}
15011539

15021540
sawMessageStop := false
1541+
var refusalMeta *RefusalMetadata
15031542

15041543
for stream.Next() {
15051544
chunk := stream.Current()
@@ -1703,6 +1742,14 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S
17031742
return
17041743
}
17051744
}
1745+
case "message_delta":
1746+
// Capture stop_details on a refusal so the finish part can
1747+
// carry the reason. The pinned SDK fork (v1.26.0 base) does
1748+
// not model stop_details (upstream added it in v1.29.0), so
1749+
// parse it from the raw delta JSON.
1750+
if rm := parseAnthropicRefusal(chunk.AsMessageDelta().Delta.RawJSON()); rm != nil {
1751+
refusalMeta = rm
1752+
}
17061753
case "message_stop":
17071754
sawMessageStop = true
17081755
}
@@ -1743,7 +1790,7 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S
17431790
CacheCreationTokens: acc.Usage.CacheCreationInputTokens,
17441791
CacheReadTokens: acc.Usage.CacheReadInputTokens,
17451792
},
1746-
ProviderMetadata: fantasy.ProviderMetadata{},
1793+
ProviderMetadata: refusalProviderMetadata(refusalMeta),
17471794
})
17481795
}, nil
17491796
}

‎providers/anthropic/provider_options.go‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const (
4343
TypeReasoningOptionMetadata = Name + ".reasoning_metadata"
4444
TypeProviderCacheControl = Name + ".cache_control_options"
4545
TypeWebSearchResultMetadata = Name + ".web_search_result_metadata"
46+
TypeRefusalMetadata = Name + ".refusal_metadata"
4647
)
4748

4849
// Register Anthropic provider-specific types with the global registry.
@@ -75,6 +76,58 @@ func init() {
7576
}
7677
return &v, nil
7778
})
79+
fantasy.RegisterProviderType(TypeRefusalMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
80+
var v RefusalMetadata
81+
if err := json.Unmarshal(data, &v); err != nil {
82+
return nil, err
83+
}
84+
return &v, nil
85+
})
86+
}
87+
88+
// RefusalMetadata carries the details Anthropic emits in stop_details when a
89+
// response is stopped by its real-time safety classifiers (stop_reason
90+
// "refusal"). It is attached to the finish stream part so callers can surface
91+
// why a turn produced no content.
92+
type RefusalMetadata struct {
93+
// Category is the refusal category, e.g. "cyber".
94+
Category string `json:"category"`
95+
// Explanation is the human-readable explanation, including any help link.
96+
Explanation string `json:"explanation"`
97+
}
98+
99+
// Options implements the ProviderOptionsData interface.
100+
func (*RefusalMetadata) Options() {}
101+
102+
// MarshalJSON implements custom JSON marshaling with type info for RefusalMetadata.
103+
func (m RefusalMetadata) MarshalJSON() ([]byte, error) {
104+
type plain RefusalMetadata
105+
return fantasy.MarshalProviderType(TypeRefusalMetadata, plain(m))
106+
}
107+
108+
// UnmarshalJSON implements custom JSON unmarshaling with type info for RefusalMetadata.
109+
func (m *RefusalMetadata) UnmarshalJSON(data []byte) error {
110+
type plain RefusalMetadata
111+
var p plain
112+
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
113+
return err
114+
}
115+
*m = RefusalMetadata(p)
116+
return nil
117+
}
118+
119+
// GetRefusalMetadata returns the Anthropic refusal metadata from a provider
120+
// metadata map, or nil when absent.
121+
func GetRefusalMetadata(metadata fantasy.ProviderMetadata) *RefusalMetadata {
122+
if metadata == nil {
123+
return nil
124+
}
125+
if data, ok := metadata[Name]; ok {
126+
if refusal, ok := data.(*RefusalMetadata); ok {
127+
return refusal
128+
}
129+
}
130+
return nil
78131
}
79132

80133
// ProviderOptions represents additional options for the Anthropic provider.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package anthropic
2+
3+
import (
4+
"testing"
5+
6+
fantasy "charm.land/fantasy"
7+
)
8+
9+
func TestMapFinishReasonRefusal(t *testing.T) {
10+
t.Parallel()
11+
12+
cases := map[string]fantasy.FinishReason{
13+
"end_turn": fantasy.FinishReasonStop,
14+
"pause_turn": fantasy.FinishReasonStop,
15+
"stop_sequence": fantasy.FinishReasonStop,
16+
"max_tokens": fantasy.FinishReasonLength,
17+
"tool_use": fantasy.FinishReasonToolCalls,
18+
"refusal": fantasy.FinishReasonContentFilter,
19+
"": fantasy.FinishReasonUnknown,
20+
"something_new": fantasy.FinishReasonUnknown,
21+
}
22+
for in, want := range cases {
23+
if got := mapFinishReason(in); got != want {
24+
t.Errorf("mapFinishReason(%q) = %q, want %q", in, got, want)
25+
}
26+
}
27+
}
28+
29+
func TestParseAnthropicRefusal(t *testing.T) {
30+
t.Parallel()
31+
32+
t.Run("Refusal", func(t *testing.T) {
33+
t.Parallel()
34+
delta := `{"stop_reason":"refusal","stop_details":{"type":"refusal","category":"cyber","explanation":"blocked under policy"}}`
35+
got := parseAnthropicRefusal(delta)
36+
if got == nil {
37+
t.Fatal("expected refusal metadata, got nil")
38+
}
39+
if got.Category != "cyber" {
40+
t.Errorf("category = %q, want cyber", got.Category)
41+
}
42+
if got.Explanation != "blocked under policy" {
43+
t.Errorf("explanation = %q, want %q", got.Explanation, "blocked under policy")
44+
}
45+
})
46+
47+
t.Run("NoStopDetails", func(t *testing.T) {
48+
t.Parallel()
49+
if got := parseAnthropicRefusal(`{"stop_reason":"end_turn"}`); got != nil {
50+
t.Errorf("expected nil, got %+v", got)
51+
}
52+
})
53+
54+
t.Run("EmptyAndInvalid", func(t *testing.T) {
55+
t.Parallel()
56+
if got := parseAnthropicRefusal(""); got != nil {
57+
t.Errorf("expected nil for empty, got %+v", got)
58+
}
59+
if got := parseAnthropicRefusal("not json"); got != nil {
60+
t.Errorf("expected nil for invalid, got %+v", got)
61+
}
62+
})
63+
}
64+
65+
func TestRefusalMetadataRoundTrip(t *testing.T) {
66+
t.Parallel()
67+
68+
in := refusalProviderMetadata(&RefusalMetadata{Category: "cyber", Explanation: "blocked"})
69+
got := GetRefusalMetadata(in)
70+
if got == nil {
71+
t.Fatal("expected refusal metadata, got nil")
72+
}
73+
if got.Category != "cyber" || got.Explanation != "blocked" {
74+
t.Errorf("unexpected metadata: %+v", got)
75+
}
76+
if GetRefusalMetadata(refusalProviderMetadata(nil)) != nil {
77+
t.Error("expected nil metadata for nil refusal")
78+
}
79+
}

0 commit comments

Comments
 (0)