diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 2dc1cecd6a7..1e64f2a9a63 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -451,6 +451,9 @@ const AnthropicStreamDelta = Schema.Struct({ signature: Schema.optional(Schema.String), stop_reason: optionalNull(Schema.String), stop_sequence: optionalNull(Schema.String), + stop_details: optionalNull( + Schema.Struct({ category: optionalNull(Schema.String), explanation: optionalNull(Schema.String) }), + ), }) type AnthropicStreamDelta = Schema.Schema.Type const decodeAnthropicStreamDelta = Schema.decodeUnknownOption(AnthropicStreamDelta) @@ -1417,10 +1420,14 @@ const onMessageDelta = ( stopSequence === null || stopSequence === undefined ? state.pendingFinish?.providerMetadata : providerMetadata(state.providerMetadataKey, { stopSequence }) + const category = event.delta?.stop_details?.category + const explanation = event.delta?.stop_details?.explanation return { reason: { normalized: mapFinishReason(stopReason), raw: stopReason, + ...(category ? { category } : {}), + ...(explanation ? { explanation } : {}), }, providerMetadata: finishMetadata, } diff --git a/packages/ai/src/schema/events.ts b/packages/ai/src/schema/events.ts index 7807bb31056..2d06eb9ae15 100644 --- a/packages/ai/src/schema/events.ts +++ b/packages/ai/src/schema/events.ts @@ -264,6 +264,10 @@ export type ToolError = Schema.Schema.Type export const FinishReasonDetails = Schema.Struct({ normalized: FinishReason, raw: Schema.optional(Schema.String), + /** The provider's policy area for a content-filter finish, such as `cyber`. */ + category: Schema.optional(Schema.String), + /** The provider's human-readable reason for a content-filter finish. */ + explanation: Schema.optional(Schema.String), }).annotate({ identifier: "LLM.FinishReasonDetails" }) export type FinishReasonDetails = Schema.Schema.Type diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index 20d5b6bcfeb..46b1fdc9d6a 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -1673,6 +1673,40 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("carries a refusal's category and explanation on the content-filter finish", () => + Effect.gen(function* () { + const refusal = (stop_details: unknown) => + LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { type: "message_delta", delta: { stop_reason: "refusal", stop_details }, usage: { output_tokens: 0 } }, + { type: "message_stop" }, + ), + ), + ), + ) + + expect( + (yield* refusal({ + type: "refusal", + category: "cyber", + explanation: "This request was declined because it could enable cyber harm.", + })).finishReason, + ).toEqual({ + normalized: "content-filter", + raw: "refusal", + category: "cyber", + explanation: "This request was declined because it could enable cyber harm.", + }) + expect((yield* refusal({ type: "refusal", category: null, explanation: null })).finishReason).toEqual({ + normalized: "content-filter", + raw: "refusal", + }) + }), + ) + it.effect("assembles streamed tool call input", () => Effect.gen(function* () { const body = sseEvents( diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 03367a8ed1b..988afb70b0e 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -543,7 +543,15 @@ export const createLLMEventPublisher = (bus: Pick, inp } if (event.reason.normalized === "content-filter") { providerFailed = true - yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }) + yield* failAssistant({ + type: "provider.content-filter", + message: [ + `Provider blocked the response${event.reason.category === undefined ? "" : ` (${event.reason.category})`}`, + event.reason.explanation, + ] + .filter(Boolean) + .join(": "), + }) return } return diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 2c09ea71f5f..ccc8aa9a525 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -667,3 +667,29 @@ test("content-filter finish preserves partial streamed text and never ends the s error: { type: "provider.content-filter" }, }) }) + +test("content-filter failure explains the refusal when the provider gives a reason", async () => { + const { published, publisher } = capture() + await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 }))) + await Effect.runPromise( + publisher.publish( + LLMEvent.stepFinish({ + index: 0, + reason: { + normalized: "content-filter", + raw: "refusal", + category: "cyber", + explanation: "This request was declined because it could enable cyber harm.", + }, + }), + ), + ) + await Effect.runPromise(publisher.publishStepFailure()) + + expect(published.at(-1)?.data).toMatchObject({ + error: { + type: "provider.content-filter", + message: "Provider blocked the response (cyber): This request was declined because it could enable cyber harm.", + }, + }) +})