Implemented in ~/openai-compat/ — a minimal OpenAI-compatible FastAPI server. The root cause of CH-GAP-030: the request model never declared stream, so pydantic's default extras handling silently discarded stream: true and the handler answered a streaming request with a non-streaming 200.
Implemented in ~/openai-compat/ — a minimal OpenAI-compatible FastAPI server. The root cause of CH-GAP-030: the request model never declared stream, so pydantic's default extras handling silently discarded stream: true and the handler answered a streaming request with a non-streaming 200.
1. Declare the field on the request model (openai_compat/models.py) — plus extra="forbid" so nothing top-level is ever silently dropped again:
class ChatCompletionRequest(BaseModel):
model: str
messages: list[ChatMessage] = Field(min_length=1)
temperature: float | None = Field(default=None, ge=0.0, le=2.0)
# ...
# CH-GAP-030 fix: declared, so it can never be swallowed by pydantic extras.
# strict=True keeps OpenAI type semantics: only true/false/null are valid.
stream: bool | None = Field(default=None, strict=True)
model_config = ConfigDict(extra="forbid")
2. Reject the unsupported value explicitly (openai_compat/main.py) — the handler raises a domain exception mapped to an OpenAI-style 400, never a silent 200:
@app.post("/v1/chat/completions")
async def chat_completions(body: ChatCompletionRequest) -> ChatCompletionResponse:
if body.stream: # CH-GAP-030: reject loudly instead of silently un-streaming
raise OpenAICompatError(
"Streaming is not supported by this server. Omit `stream` or set "
"`stream` to false to receive a non-streaming completion.",
code="stream_not_supported",
param="stream",
)
# ... non-streaming 200 path (stream=False / omitted / null)
3. One OpenAI error envelope for the whole API (openai_compat/errors.py) — custom exception handler plus a RequestValidationError handler that converts FastAPI/pydantic 422s into the same OpenAI-style 400 shape:
{"error": {"message": "...", "type": "invalid_request_error", "param": "stream", "code": "stream_not_supported"}}
4. Documented in OPENAI_API.md (request schema, the before/after stream behavior, the stream_not_supported example, unknown-param rejection, and an error-format table).
Automated tests — tests/test_chat_completions.py, 13/13 pass (pytest -q, Python 3.12, FastAPI TestClient):
test_stream_true_is_rejected_with_openai_style_400 — asserts HTTP 400, code=stream_not_supported, param=stream, type=invalid_request_error, application/jsontest_stream_true_is_never_a_silent_200 — regression guard (no choices key, status ≠ 200)test_stream_false_happy_path_returns_completion / test_stream_omitted_happy_path / test_stream_null_happy_path — 200 with full chat.completion shape (id, choices[0].message.role=assistant, usage arithmetic consistent)stream:"yes" → 400 type error (param=stream, code=null); unknown bogus_param → 400 "Unrecognized request argument supplied: bogus_param"; empty messages → 400 param=messages; missing model → 400; invalid role → 400; malformed JSON → 400 not 500; error body parses as OpenAIErrorResponseLive server verification (uvicorn on :8321, curl end-to-end):
| Request | Result |
|---|---|
"stream": true |
HTTP 400 {"error":{...,"param":"stream","code":"stream_not_supported"}} |
"stream": false |
HTTP 200 chat.completion |
stream omitted |
HTTP 200 chat.completion |
"stream": "yes" |
HTTP 400 {"error":{...,"param":"stream","code":null}} (strict type, no silent coercion) |
"bogus_param": 1 |
HTTP 400 Unrecognized request argument supplied: bogus_param |
Model introspection confirms the fix mechanics: stream ∈ model_fields with annotation bool | None and model_config.extra = "forbid" — an undeclared field can no longer exist to be dropped.
JSON: {"problem_class":"python-openai-compat-param-validation","model":"deepseek-v4-flash","result":"passed","tests":13}
Solved by Pi Agent (deepseek-v4-flash).
Implemented in ~/openai-compat/ — a minimal OpenAI-compatible FastAPI server. The root cause of CH-GAP-030: the request model never declared stream, so pydantic's default extras handling silently discarded stream: true and the handler answered a streaming request with a non-streaming 200.
1. Declare the field on the request model (openai_compat/models.py) — plus extra="forbid" so nothing top-level is ever silently dropped again:
class ChatCompletionRequest(BaseModel):
model: str
messages: list[ChatMessage] = Field(min_length=1)
temperature: float | None = Field(default=None, ge=0.0, le=2.0)
# ...
# CH-GAP-030 fix: declared, so it can never be swallowed by pydantic extras.
# strict=True keeps OpenAI type semantics: only true/false/null are valid.
stream: bool | None = Field(default=None, strict=True)
model_config = ConfigDict(extra="forbid")
2. Reject the unsupported value explicitly (openai_compat/main.py) — the handler raises a domain exception mapped to an OpenAI-style 400, never a silent 200:
@app.post("/v1/chat/completions")
async def chat_completions(body: ChatCompletionRequest) -> ChatCompletionResponse:
if body.stream: # CH-GAP-030: reject loudly instead of silently un-streaming
raise OpenAICompatError(
"Streaming is not supported by this server. Omit `stream` or set "
"`stream` to false to receive a non-streaming completion.",
code="stream_not_supported",
param="stream",
)
# ... non-streaming 200 path (stream=False / omitted / null)
3. One OpenAI error envelope for the whole API (openai_compat/errors.py) — custom exception handler plus a RequestValidationError handler that converts FastAPI/pydantic 422s into the same OpenAI-style 400 shape:
{"error": {"message": "...", "type": "invalid_request_error", "param": "stream", "code": "stream_not_supported"}}
4. Documented in OPENAI_API.md (request schema, the before/after stream behavior, the stream_not_supported example, unknown-param rejection, and an error-format table).
Automated tests — tests/test_chat_completions.py, 13/13 pass (pytest -q, Python 3.12, FastAPI TestClient):
test_stream_true_is_rejected_with_openai_style_400 — asserts HTTP 400, code=stream_not_supported, param=stream, type=invalid_request_error, application/jsontest_stream_true_is_never_a_silent_200 — regression guard (no choices key, status ≠ 200)test_stream_false_happy_path_returns_completion / test_stream_omitted_happy_path / test_stream_null_happy_path — 200 with full chat.completion shape (id, choices[0].message.role=assistant, usage arithmetic consistent)stream:"yes" → 400 type error (param=stream, code=null); unknown bogus_param → 400 "Unrecognized request argument supplied: bogus_param"; empty messages → 400 param=messages; missing model → 400; invalid role → 400; malformed JSON → 400 not 500; error body parses as OpenAIErrorResponseLive server verification (uvicorn on :8321, curl end-to-end):
| Request | Result |
|---|---|
"stream": true |
HTTP 400 {"error":{...,"param":"stream","code":"stream_not_supported"}} |
"stream": false |
HTTP 200 chat.completion |
stream omitted |
HTTP 200 chat.completion |
"stream": "yes" |
HTTP 400 {"error":{...,"param":"stream","code":null}} (strict type, no silent coercion) |
"bogus_param": 1 |
HTTP 400 Unrecognized request argument supplied: bogus_param |
Model introspection confirms the fix mechanics: stream ∈ model_fields with annotation bool | None and model_config.extra = "forbid" — an undeclared field can no longer exist to be dropped.
JSON: {"problem_class":"python-openai-compat-param-validation","model":"deepseek-v4-flash","result":"passed","tests":13}
Solved by Pi Agent (deepseek-v4-flash).