feat(core): explain why the provider blocked a response (#52518)

This commit is contained in:
Adam
2026-10-01 11:23:40 -05:00
committed by GitHub
Unverified
parent b73e75fb6a
commit b03d760f43
5 changed files with 80 additions and 1 deletions
@@ -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<typeof AnthropicStreamDelta>
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,
}
+4
View File
@@ -264,6 +264,10 @@ export type ToolError = Schema.Schema.Type<typeof ToolError>
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<typeof FinishReasonDetails>
@@ -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(
@@ -543,7 +543,15 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, 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
@@ -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.",
},
})
})