diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index fdf9fd8f5e..6d05be2310 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -1959,8 +1959,11 @@ async def openai_chat_completions( Supports multimodal messages: ``content`` may be a plain string or a list of content parts (``text`` / ``image_url``). - Streaming (default): returns SSE chunks matching OpenAI's format. - Non-streaming: returns a single ChatCompletion JSON object. + Non-streaming (default): returns a single ChatCompletion JSON object. + Streaming: returns SSE chunks matching OpenAI's format. + + ``stream`` defaults to ``false`` to match OpenAI's spec; clients opt + into SSE by sending ``stream: true``. Automatically routes to the correct backend: - GGUF models → llama-server via LlamaCppBackend @@ -2184,6 +2187,9 @@ async def openai_chat_completions( cancel_event = threading.Event() completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}" + # `stream` defaults to False on ChatCompletionRequest (OpenAI spec + # parity). Naive curl / .NET / System.Text.Json clients omitting + # the field used to get SSE here and choke on deserialization (#5047). if payload.stream: return await _openai_passthrough_stream( request, diff --git a/studio/backend/tests/test_openai_tool_passthrough.py b/studio/backend/tests/test_openai_tool_passthrough.py index a379282b70..e87faa3b32 100644 --- a/studio/backend/tests/test_openai_tool_passthrough.py +++ b/studio/backend/tests/test_openai_tool_passthrough.py @@ -299,11 +299,57 @@ class TestChatCompletionRequestToolFields: def test_stream_defaults_false_matching_openai_spec(self): # OpenAI's /v1/chat/completions spec defaults `stream` to false. # Studio previously defaulted to true, which broke naive curl - # clients that omit `stream` (they expect a JSON blob, got SSE). + # clients (and .NET / System.Text.Json SDKs per #5047) that omit + # `stream` -- they expect a JSON blob, got SSE. # Pin the corrected default so it can't silently regress. req = self._make() assert req.stream is False + def test_post_without_stream_field_decodes_to_stream_false_over_http( + self, monkeypatch + ): + # Wire-level guard for the same default: a POST body that omits + # `stream` entirely (the exact shape naive curl / .NET clients + # send) must deserialise into stream=False *and* the response + # must be `application/json`, never `text/event-stream`. + # Mounts the real `routes.inference.router` so this catches + # regressions in middleware/aliasing on the actual endpoint + # (e.g. someone adding a request layer that injects stream=True + # before pydantic builds the model). Backends are bypassed by + # routing through `provider_type` and stubbing the external + # provider proxy. + from fastapi import FastAPI + from fastapi.responses import JSONResponse + from fastapi.testclient import TestClient + + import routes.inference as inference_route + from auth.authentication import get_current_subject + + captured = {} + + async def _fake_proxy(payload, request): + captured["stream"] = payload.stream + return JSONResponse({"choices": [], "object": "chat.completion"}) + + monkeypatch.setattr(inference_route, "_proxy_to_external_provider", _fake_proxy) + + app = FastAPI() + app.include_router(inference_route.router) + app.dependency_overrides[get_current_subject] = lambda: "test-user" + + client = TestClient(app) + resp = client.post( + "/chat/completions", + json = { + "messages": [{"role": "user", "content": "hi"}], + "provider_type": "openai", + }, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("application/json") + assert "text/event-stream" not in resp.headers["content-type"] + assert captured["stream"] is False + def test_multiturn_tool_loop_messages(self): req = ChatCompletionRequest( messages = [