Check for existing issues
What happened?
A turn the provider blocked on content policy arrives on /v1/messages as "stop_reason": "end_turn", the same value an ordinary completed turn gets. The block does reach the gateway: the identical upstream response on /v1/chat/completions comes back with "finish_reason": "content_filter". It is only the Anthropic surface that loses it.
The mapping from OpenAI finish reasons to Anthropic stop reasons handles stop, length and tool_calls, and everything else falls through to end_turn. content_filter is in that fallthrough, even though refusal is already one of the stop reasons litellm declares for /v1/messages, and the reverse direction already maps Anthropic refusal to OpenAI content_filter. So a refusal that arrives over /v1/messages, is translated to chat completions and translated back, does not survive the round trip.
This is not the same as #39721, which was fixed by #39723. That one covers an upstream that sends an OpenAI refusal string, and the code reaches for the refusal stop reason only when that string is present. Providers whose blocks carry no such string still fall through. litellm's own finish reason map sends Gemini SAFETY, Bedrock guardrail_intervened, Azure content_filtered and several others to content_filter, and none of them set an OpenAI refusal field, so all of them land on end_turn.
Streaming has it too: the final message_delta carries "stop_reason": "end_turn".
Expected: a turn whose finish reason is content_filter arrives on /v1/messages as "stop_reason": "refusal", matching the value the reverse translation already produces and the one #39723 settled on for the refusal-string case.
User Flow
Before a (hypothetical) fix: a developer whose agent framework speaks the Anthropic Messages API cannot tell a blocked answer from an empty one, so the agent retries or reports success on a request the provider refused
- The proxy admin configures a model whose provider blocks unsafe prompts, and restarts the proxy
- The developer sends POST https://litellm-domain/v1/chat/completions with a prompt the provider blocks, and gets HTTP 200 with
"finish_reason": "content_filter", so their app knows the answer was refused
- The developer sends the same prompt as POST https://litellm-domain/v1/messages and gets HTTP 200 with
"content": [] and "stop_reason": "end_turn"
- They send an ordinary prompt to the same route and get HTTP 200 with
"stop_reason": "end_turn" as well, so the two responses differ only by whether content is empty, which is also what a legitimately empty turn looks like
- They try
"stream": true and the closing message_delta also reads "stop_reason": "end_turn"
- Their agent loop treats the refusal as a normal turn and either retries the same blocked prompt or records the request as answered
After a (hypothetical) fix: the same requests tell the developer the answer was refused
- The proxy admin configures the same model and restarts the proxy
- POST https://litellm-domain/v1/chat/completions returns HTTP 200 with
"finish_reason": "content_filter", unchanged
- POST https://litellm-domain/v1/messages now returns HTTP 200 with
"stop_reason": "refusal"
- An ordinary prompt still returns
"stop_reason": "end_turn", so the two are distinguishable
- With
"stream": true the closing message_delta reads "stop_reason": "refusal"
- Their agent loop sees the refusal and stops instead of retrying
Proof the bug occurs
I have no key for a provider that content-filters, so the deployment points at a local OpenAI-compatible stub that answers with the shape such a provider produces: no content, finish_reason content_filter, and no OpenAI refusal field. Everything from the proxy inwards is real, and the same run shows an ordinary turn through the same routes for comparison. Happy to redo it against a live provider if you would rather see that.
Config the proxy ran with:
model_list:
- model_name: filtered-model
litellm_params:
model: hosted_vllm/filtered-model
api_key: stub-key-no-provider-call
api_base: http://127.0.0.1:8899/v1
general_settings:
master_key: sk-1234
The stub, which answers a filtered turn when the prompt contains "filters" and an ordinary one otherwise:
import json, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_POST(self):
req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0)) or 0) or b"{}")
filtered = "filters" in json.dumps(req.get("messages", ""))
reason = "content_filter" if filtered else "stop"
content = None if filtered else "here you go"
if not req.get("stream"):
payload = json.dumps({
"id": "chatcmpl-stub", "object": "chat.completion", "created": int(time.time()),
"model": "filtered-model",
"choices": [{"index": 0, "message": {"role": "assistant", "content": content},
"finish_reason": reason}],
"usage": {"prompt_tokens": 12, "completion_tokens": 0, "total_tokens": 12},
}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
return
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Connection", "close")
self.end_headers()
for delta, finish in (({"role": "assistant", "content": content or ""}, None), ({}, reason)):
d = {"id": "chatcmpl-stub", "object": "chat.completion.chunk", "created": int(time.time()),
"model": "filtered-model", "choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}
self.wfile.write(f"data: {json.dumps(d)}\n\n".encode())
self.wfile.flush()
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
self.close_connection = True
def log_message(self, *a):
pass
ThreadingHTTPServer(("127.0.0.1", 8899), H).serve_forever()
Commit: 9a715df (tip of litellm_internal_staging)
H=(-H "Authorization: Bearer sk-1234" -H "Content-Type: application/json")
Case 1, the provider filters the turn.
$ curl -s -w '\nHTTP %{http_code}\n' http://localhost:4000/v1/chat/completions ${H[@]} -d '{"model":"filtered-model","messages":[{"role":"user","content":"something the provider filters"}]}'
{"id":"chatcmpl-stub","created":1789223216,"model":"filtered-model","object":"chat.completion","choices":[{"finish_reason":"content_filter","index":0,"message":{"role":"assistant","content":null},"provider_specific_fields":{}}],"usage":{"completion_tokens":0,"prompt_tokens":12,"total_tokens":12}}
HTTP 200
$ curl -s -w '\nHTTP %{http_code}\n' http://localhost:4000/v1/messages ${H[@]} -d '{"model":"filtered-model","max_tokens":256,"messages":[{"role":"user","content":"something the provider filters"}]}'
{"id":"chatcmpl-stub","type":"message","role":"assistant","model":"filtered-model","stop_sequence":null,"usage":{"input_tokens":12,"output_tokens":0},"content":[],"stop_reason":"end_turn","stop_details":null}
HTTP 200
Case 2, an ordinary answer over the same routes.
$ curl -s -w '\nHTTP %{http_code}\n' http://localhost:4000/v1/chat/completions ${H[@]} -d '{"model":"filtered-model","messages":[{"role":"user","content":"say hello"}]}'
{"id":"chatcmpl-stub","created":1789223216,"model":"filtered-model","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"here you go","role":"assistant"},"provider_specific_fields":{}}],"usage":{"completion_tokens":0,"prompt_tokens":12,"total_tokens":12}}
HTTP 200
$ curl -s -w '\nHTTP %{http_code}\n' http://localhost:4000/v1/messages ${H[@]} -d '{"model":"filtered-model","max_tokens":256,"messages":[{"role":"user","content":"say hello"}]}'
{"id":"chatcmpl-stub","type":"message","role":"assistant","model":"filtered-model","stop_sequence":null,"usage":{"input_tokens":12,"output_tokens":0},"content":[{"type":"text","text":"here you go"}],"stop_reason":"end_turn","stop_details":null}
HTTP 200
The blocked turn and the answered turn carry the same stop_reason.
Case 3, the same filtered turn with "stream": true.
$ curl -sN -w '\nHTTP %{http_code}\n' http://localhost:4000/v1/messages ${H[@]} -d '{"model":"filtered-model","max_tokens":256,"stream":true,"messages":[{"role":"user","content":"something the provider filters"}]}'
event: message_start
data: {"type": "message_start", "message": {"id": "msg_3846e5f1-8cc6-4ea3-b61a-ef755ffd4bcc", "type": "message", "role": "assistant", "content": [], "model": "filtered-model", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}}
event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 11, "output_tokens": 0}}
HTTP 200
What part of LiteLLM is this about?
Proxy
What LiteLLM version are you on ?
9a715df
Twitter / LinkedIn details
No response
Check for existing issues
What happened?
A turn the provider blocked on content policy arrives on
/v1/messagesas"stop_reason": "end_turn", the same value an ordinary completed turn gets. The block does reach the gateway: the identical upstream response on/v1/chat/completionscomes back with"finish_reason": "content_filter". It is only the Anthropic surface that loses it.The mapping from OpenAI finish reasons to Anthropic stop reasons handles
stop,lengthandtool_calls, and everything else falls through toend_turn.content_filteris in that fallthrough, even thoughrefusalis already one of the stop reasons litellm declares for/v1/messages, and the reverse direction already maps Anthropicrefusalto OpenAIcontent_filter. So a refusal that arrives over/v1/messages, is translated to chat completions and translated back, does not survive the round trip.This is not the same as #39721, which was fixed by #39723. That one covers an upstream that sends an OpenAI
refusalstring, and the code reaches for therefusalstop reason only when that string is present. Providers whose blocks carry no such string still fall through. litellm's own finish reason map sends GeminiSAFETY, Bedrockguardrail_intervened, Azurecontent_filteredand several others tocontent_filter, and none of them set an OpenAIrefusalfield, so all of them land onend_turn.Streaming has it too: the final
message_deltacarries"stop_reason": "end_turn".Expected: a turn whose finish reason is
content_filterarrives on/v1/messagesas"stop_reason": "refusal", matching the value the reverse translation already produces and the one #39723 settled on for the refusal-string case.User Flow
Before a (hypothetical) fix: a developer whose agent framework speaks the Anthropic Messages API cannot tell a blocked answer from an empty one, so the agent retries or reports success on a request the provider refused
"finish_reason": "content_filter", so their app knows the answer was refused"content": []and"stop_reason": "end_turn""stop_reason": "end_turn"as well, so the two responses differ only by whethercontentis empty, which is also what a legitimately empty turn looks like"stream": trueand the closingmessage_deltaalso reads"stop_reason": "end_turn"After a (hypothetical) fix: the same requests tell the developer the answer was refused
"finish_reason": "content_filter", unchanged"stop_reason": "refusal""stop_reason": "end_turn", so the two are distinguishable"stream": truethe closingmessage_deltareads"stop_reason": "refusal"Proof the bug occurs
I have no key for a provider that content-filters, so the deployment points at a local OpenAI-compatible stub that answers with the shape such a provider produces: no content,
finish_reasoncontent_filter, and no OpenAIrefusalfield. Everything from the proxy inwards is real, and the same run shows an ordinary turn through the same routes for comparison. Happy to redo it against a live provider if you would rather see that.Config the proxy ran with:
The stub, which answers a filtered turn when the prompt contains "filters" and an ordinary one otherwise:
Commit: 9a715df (tip of
litellm_internal_staging)Case 1, the provider filters the turn.
Case 2, an ordinary answer over the same routes.
The blocked turn and the answered turn carry the same
stop_reason.Case 3, the same filtered turn with
"stream": true.What part of LiteLLM is this about?
Proxy
What LiteLLM version are you on ?
9a715df
Twitter / LinkedIn details
No response