Skip to content

feat(providers/anthropic): surface refusal stop_reason as content-filter#41

Merged
ethanndickson merged 1 commit into
coder_2_33from
scott/anthropic-refusal-content-filter
Jul 9, 2026
Merged

feat(providers/anthropic): surface refusal stop_reason as content-filter#41
ethanndickson merged 1 commit into
coder_2_33from
scott/anthropic-refusal-content-filter

Conversation

@jscottmiller

Copy link
Copy Markdown

Summary

Map Anthropic's refusal stop reason to a content-filter finish reason and
preserve the block details, so downstream callers can tell a safety block apart
from a normal stop and surface why a turn produced no content.

Anthropic's real-time safety classifiers end a response with
stop_reason: "refusal" and a stop_details object (category,
explanation), and emit no content. Previously mapFinishReason fell through
to FinishReasonUnknown and the details were dropped.

Changes

  • mapFinishReason: "refusal" -> FinishReasonContentFilter.
  • Parse stop_details from the message_delta raw JSON (the Anthropic SDK does
    not model it) and attach it to the finish stream part via a new
    RefusalMetadata{Category, Explanation} provider-metadata type
    (registered, marshal/unmarshal, GetRefusalMetadata getter).

Testing

  • go test ./providers/anthropic/ (new refusal_test.go covers the finish-reason
    mapping, stop_details parsing, and metadata round-trip).

Notes

Consumed by coder/coder PR for chat "Response blocked" messaging.


Coder Agents generated.

Copy link
Copy Markdown
Author

Verified the claim in the message_delta comment ("The Anthropic SDK does not model stop_details"): true for the SDK we pin, with one nuance.

  • Our chain (charmbracelet/anthropic-sdk-go replaced by the coder/anthropic-sdk-go fork) is based on upstream v1.26.0; no stop_details/StopDetails anywhere in the module (it does model the refusal stop-reason enum).
  • Upstream added structured stop_details in v1.29.0, and v1.46.1 fixed the beta accumulator dropping stop_details from message_delta events.

So the raw-JSON parsing is the right call today. Suggested follow-ups, not blocking:

  1. Reword the comment to "the SDK fork we pin (v1.26.0 base) does not model stop_details" so it does not read as a claim about current upstream.
  2. When the fork is rebased onto >= v1.29.0 (ideally >= v1.46.1), replace parseAnthropicRefusal with the typed field.

Coder Agents generated (on behalf of @jscottmiller).

Copy link
Copy Markdown
Author

Merge-order note relative to #40 (the main -> coder_2_33 sync):

  • A test merge (git merge-tree) of the two branches shows this PR adds exactly one conflicted file to the sync, providers/anthropic/anthropic.go; provider_options.go and the new test auto-merge. chore: merge main into coder_2_33 #40 already conflicts with current coder_2_33 in two OpenAI files (it was cut before fix(providers/anthropic): send thinking display #39 merged), so it needs a refresh regardless.
  • Resolution is mechanical either way: upstream main keeps the same structure at all three insertion points (mapFinishReason switch, stream event switch, finish-part ProviderMetadata), and contains no refusal/stop_details handling of its own.
  • chore: merge main into coder_2_33 #40 does not bump the coder/anthropic-sdk-go pin (still v1.26.0 base), so the raw-JSON stop_details parsing here remains necessary after the sync.

Since the refusal mapping is not Coder-specific, it is also a candidate to send upstream to charmbracelet/fantasy rather than carrying it as a fork patch long-term.


Coder Agents generated (on behalf of @jscottmiller).

Copy link
Copy Markdown
Author

/coder-agents-review

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.
@ethanndickson
ethanndickson force-pushed the scott/anthropic-refusal-content-filter branch from 654801e to 798e38d Compare July 9, 2026 06:16
@linear-code

linear-code Bot commented Jul 9, 2026

Copy link
Copy Markdown

CODAGT-611

@ethanndickson
ethanndickson marked this pull request as ready for review July 9, 2026 06:17
@ethanndickson

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 798e38d74a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ethanndickson
ethanndickson merged commit 4765514 into coder_2_33 Jul 9, 2026
10 of 11 checks passed
ethanndickson added a commit to coder/coder that referenced this pull request Jul 9, 2026
When a provider's real-time safety classifiers block a response
(e.g. Anthropic stop_reason "refusal"), the turn previously ended
silently: the fantasy SDK mapped the stop reason to unknown and the
empty-content finish looked like a normal stop, leaving the UI stuck
on "Thinking" with no output.

Bump the fantasy fork to pick up refusal -> FinishReasonContentFilter
mapping with stop_details carried as RefusalMetadata (fork change 15,
coder/fantasy#41). chatloop now turns an empty content-filter finish
into a terminal, non-retryable content_filter classified error, using
the provider's explanation as the message and the refusal category as
the detail, with a generic per-provider fallback message. The UI
renders it as "Response blocked".

Partial-content refusals (classifier blocks after some output) still
persist the partial text and end the turn normally; surfacing those is
a separate product decision.

Coder Agents generated.
ethanndickson added a commit to coder/coder that referenced this pull request Jul 9, 2026
When a provider's real-time safety classifiers block a response
(e.g. Anthropic stop_reason "refusal"), the turn previously ended
silently: the fantasy SDK mapped the stop reason to unknown and the
empty-content finish looked like a normal stop, leaving the UI stuck
on "Thinking" with no output.

Bump the fantasy fork to pick up refusal -> FinishReasonContentFilter
mapping with stop_details carried as RefusalMetadata (fork change 15,
coder/fantasy#41). chatloop now turns an empty content-filter finish
into a terminal, non-retryable content_filter classified error, using
the provider's explanation as the message and the refusal category as
the detail, with a generic per-provider fallback message. The UI
renders it as "Response blocked".

Partial-content refusals (classifier blocks after some output) still
persist the partial text and end the turn normally; surfacing those is
a separate product decision.

Coder Agents generated.
ibetitsmike added a commit to coder/coder that referenced this pull request Jul 9, 2026
gpt-5.6 models were unusable with agents: fantasy's Responses allowlist
did not include the new family, so `IsResponsesModel` returned false and
chatd fell back to the Chat Completions path (no reasoning params, no
encrypted reasoning continuity).

## Changes

- Bump the `charm.land/fantasy` replace pin to coder/fantasy
`6da0c3b10237` (coder_2_33), pulling in:
- coder/fantasy#46: route `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`
through the OpenAI Responses API as reasoning models.
- coder/fantasy#41: surface Anthropic refusal stop_reason as
content-filter (already on coder_2_33, rides along with the bump).
- Update the fork changelog comment in go.mod.

## Verification

- Probed all three gpt-5.6 models through the ai-gateway:
`/v1/responses` with `reasoning.effort`, `include:
["reasoning.encrypted_content"]`, and `store: false` completes for each.
- `go build ./coderd/...` and `go test ./coderd/x/chatd/chatopenai/
./coderd/x/chatd/chatprovider/` pass against the new pin.

> This PR was authored by Mux on Mike's behalf.
mtojek pushed a commit to coder/coder that referenced this pull request Jul 13, 2026
Cherry-pick of #27132 (`f84801ee`) onto `release/2.35`.

gpt-5.6 models were unusable with agents: fantasy's Responses allowlist
did not include the new family, so `IsResponsesModel` returned false and
chatd fell back to the Chat Completions path (no reasoning params, no
encrypted reasoning continuity).

## Changes

- Bump the `charm.land/fantasy` replace pin from `2e3ddbca75dd` to
coder/fantasy `6da0c3b10237` (coder_2_33), pulling in:
- coder/fantasy#46: route `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`
through the OpenAI Responses API as reasoning models.
- coder/fantasy#41: surface Anthropic refusal stop_reason as
content-filter (rides along; sits below #46 on coder_2_33).
- Update the fork changelog comment in go.mod.

## Backport safety

- The delta over 2.35's pin is exactly the two commits above; #27132
itself touched only go.mod/go.sum, and no coder/coder code references
the refusal metadata or content-filter finish reason (verified on main
and 2.35).
- Finish-part provider metadata is never persisted by chatd, so a
rollback to an unpatched 2.35.x binary cannot hit undecodable stored
history.
- `go build ./coderd/...` and `go test
./coderd/x/chatd/{chatopenai,chatprovider,chatprompt,chatloop}/` pass on
this branch; `go mod tidy` is a no-op.

> This PR was authored by Mux on Mike's behalf.
ethanndickson added a commit to coder/coder that referenced this pull request Jul 13, 2026
When a provider's real-time safety classifiers block a response
(e.g. Anthropic stop_reason "refusal"), the turn previously ended
silently: the fantasy SDK mapped the stop reason to unknown and the
empty-content finish looked like a normal stop, leaving the UI stuck
on "Thinking" with no output.

Bump the fantasy fork to pick up refusal -> FinishReasonContentFilter
mapping with stop_details carried as RefusalMetadata (fork change 15,
coder/fantasy#41). chatloop now turns an empty content-filter finish
into a terminal, non-retryable content_filter classified error, using
the provider's explanation as the message and the refusal category as
the detail, with a generic per-provider fallback message. The UI
renders it as "Response blocked".

Partial-content refusals (classifier blocks after some output) still
persist the partial text and end the turn normally; surfacing those is
a separate product decision.

Coder Agents generated.
ethanndickson added a commit to coder/coder that referenced this pull request Jul 14, 2026
When a provider's real-time safety classifiers block a response
(e.g. Anthropic stop_reason "refusal"), the turn previously ended
silently: the fantasy SDK mapped the stop reason to unknown and the
empty-content finish looked like a normal stop, leaving the UI stuck
on "Thinking" with no output.

Bump the fantasy fork to pick up refusal -> FinishReasonContentFilter
mapping with stop_details carried as RefusalMetadata (fork change 15,
coder/fantasy#41). chatloop now turns an empty content-filter finish
into a terminal, non-retryable content_filter classified error, using
the provider's explanation as the message and the refusal category as
the detail, with a generic per-provider fallback message. The UI
renders it as "Response blocked".

Partial-content refusals (classifier blocks after some output) still
persist the partial text and end the turn normally; surfacing those is
a separate product decision.

Coder Agents generated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants