Skip to content

Commit b77076d

Browse files
fix: preserve chat stream moderation results (#3864)
Preserves moderation results in chat stream snapshots and final completions, including reports delivered after content. Later omitted or null fields retain the last report; unmoderated streams remain unchanged. Validation: 324 tests passed (14 skipped), plus 19 focused tests with Pydantic v1. Ruff, mypy, changed-file formatting, and wheel/sdist builds pass.
1 parent 98e1d24 commit b77076d

2 files changed

Lines changed: 128 additions & 1 deletion

File tree

‎src/openai/lib/streaming/chat/_completions.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
from ...._streaming import Stream, AsyncStream
3939
from ....types.chat import ChatCompletionChunk, ParsedChatCompletion, ChatCompletionToolUnionParam
4040
from ...._exceptions import LengthFinishReasonError, ContentFilterFinishReasonError
41-
from ....types.chat.chat_completion import ChoiceLogprobs
41+
from ....types.chat.chat_completion import Moderation, ChoiceLogprobs
4242
from ....types.chat.chat_completion_chunk import Choice as ChoiceChunk
4343
from ....types.chat.completion_create_params import ResponseFormat as ResponseFormatParam
4444

@@ -487,6 +487,10 @@ def _accumulate_chunk(self, chunk: ChatCompletionChunk) -> ParsedChatCompletionS
487487

488488
completion_snapshot.usage = chunk.usage
489489
completion_snapshot.system_fingerprint = chunk.system_fingerprint
490+
if chunk.moderation is not None:
491+
completion_snapshot.moderation = cast(
492+
Moderation, construct_type(type_=Moderation, value=chunk.moderation.to_dict())
493+
)
490494

491495
return completion_snapshot
492496

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from typing import Any
5+
6+
import httpx2
7+
import pytest
8+
9+
from openai import OpenAI, AsyncOpenAI
10+
from tests.respx2 import MockRouter
11+
from openai.lib.streaming.chat import ChatCompletionStreamEvent
12+
from openai.types.chat.chat_completion import Moderation
13+
14+
from ...conftest import base_url
15+
16+
17+
def moderation_result(*, flagged: bool) -> dict[str, Any]:
18+
return {
19+
"type": "moderation_results",
20+
"model": "test-moderation",
21+
"results": [
22+
{
23+
"type": "moderation_result",
24+
"model": "test-moderation",
25+
"flagged": flagged,
26+
"categories": {"violence": flagged},
27+
"category_scores": {"violence": 0.75 if flagged else 0.0},
28+
"category_applied_input_types": {"violence": ["text"]},
29+
}
30+
],
31+
}
32+
33+
34+
@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"])
35+
@pytest.mark.parametrize("scenario", ["flagged", "error", "input_error", "initial", "replacement", "unmoderated"])
36+
@pytest.mark.respx2(base_url=base_url)
37+
async def test_stream_moderation(
38+
sync: bool,
39+
scenario: str,
40+
client: OpenAI,
41+
async_client: AsyncOpenAI,
42+
respx2_mock: MockRouter,
43+
) -> None:
44+
success = {"input": moderation_result(flagged=False), "output": moderation_result(flagged=True)}
45+
error = {
46+
"input": moderation_result(flagged=True),
47+
"output": {"type": "error", "code": "test_error", "message": "Synthetic moderation error"},
48+
}
49+
if scenario == "input_error":
50+
error = {"input": error["output"], "output": moderation_result(flagged=False)}
51+
initial = error if scenario in {"initial", "replacement"} else None
52+
final_report = (
53+
error if scenario in {"error", "input_error"} else success if scenario in {"flagged", "replacement"} else None
54+
)
55+
expected = final_report or initial
56+
choices: list[dict[str, Any]] = [
57+
{"index": 0, "delta": {"role": "assistant", "content": "o"}, "finish_reason": None},
58+
{"index": 0, "delta": {"content": "k"}, "finish_reason": "stop"},
59+
]
60+
chunks: list[dict[str, Any]] = [
61+
{
62+
"id": "chatcmpl-test",
63+
"object": "chat.completion.chunk",
64+
"created": 0,
65+
"model": "test-model",
66+
"choices": [choices[index]] if index < 2 else [],
67+
}
68+
for index in range(4)
69+
]
70+
if initial is not None:
71+
chunks[0]["moderation"] = initial
72+
if final_report is not None:
73+
chunks[2]["moderation"] = final_report
74+
chunks[3]["moderation"] = None
75+
chunks[3]["usage"] = {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
76+
respx2_mock.post("/chat/completions").mock(
77+
return_value=httpx2.Response(
78+
200,
79+
headers={"content-type": "text/event-stream"},
80+
content="".join(f"data: {json.dumps(chunk)}\n\n" for chunk in chunks) + "data: [DONE]\n\n",
81+
)
82+
)
83+
84+
event_types: list[str] = []
85+
raw_reports: list[Any] = []
86+
snapshots: list[Any] = []
87+
88+
def record(event: ChatCompletionStreamEvent[Any]) -> None:
89+
event_types.append(event.type)
90+
if event.type == "chunk":
91+
raw_reports.append(event.chunk.moderation.to_dict() if event.chunk.moderation is not None else None)
92+
moderation = event.snapshot.moderation
93+
if moderation is not None:
94+
assert isinstance(moderation, Moderation)
95+
snapshots.append(moderation.to_dict() if moderation is not None else None)
96+
97+
if sync:
98+
raw_chunks = list(client.chat.completions.create(model="test-model", messages=[], stream=True))
99+
with client.chat.completions.stream(model="test-model", messages=[]) as stream:
100+
for event in stream:
101+
record(event)
102+
completion = stream.get_final_completion()
103+
else:
104+
raw_stream = await async_client.chat.completions.create(model="test-model", messages=[], stream=True)
105+
raw_chunks = [chunk async for chunk in raw_stream]
106+
async with async_client.chat.completions.stream(model="test-model", messages=[]) as async_stream:
107+
async for event in async_stream:
108+
record(event)
109+
completion = await async_stream.get_final_completion()
110+
111+
assert raw_reports == [initial, None, final_report, None]
112+
assert raw_reports == [chunk.moderation.to_dict() if chunk.moderation is not None else None for chunk in raw_chunks]
113+
assert snapshots == [initial, initial, expected, expected]
114+
if expected is None:
115+
assert completion.moderation is None
116+
else:
117+
assert isinstance(completion.moderation, Moderation)
118+
assert completion.moderation.to_dict() == expected
119+
assert completion.choices[0].message.content == "ok"
120+
assert completion.choices[0].finish_reason == "stop"
121+
assert completion.usage is not None
122+
assert completion.usage.total_tokens == 2
123+
assert event_types == ["chunk", "content.delta", "chunk", "content.delta", "content.done", "chunk", "chunk"]

0 commit comments

Comments
 (0)