Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion providers/anthropic/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -1649,6 +1688,14 @@ 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 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
}
case "message_stop":
sawMessageStop = true
}
Expand Down Expand Up @@ -1677,7 +1724,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
Expand Down
53 changes: 53 additions & 0 deletions providers/anthropic/provider_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
79 changes: 79 additions & 0 deletions providers/anthropic/refusal_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading