diff --git a/api/apps/restful_apis/openai_api.py b/api/apps/restful_apis/openai_api.py index 1c58e1c964..4df0eed3df 100644 --- a/api/apps/restful_apis/openai_api.py +++ b/api/apps/restful_apis/openai_api.py @@ -91,6 +91,43 @@ def _build_sse_response(body): return resp +def _normalize_message_content(content): + """Convert OpenAI message content to a string for the dialog layer. + + Supports string content and array parts with ``type: text``. Other part types + (e.g. image_url) are ignored until vision is wired through this route. + """ + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + text = part.get("text", "") + if text is not None: + parts.append(str(text)) + return "\n".join(parts) + return None + + +def _normalize_openai_messages(messages): + """Return (normalized_messages, error_message). error_message is set on failure.""" + if not isinstance(messages, list): + return None, "messages must be an array." + + normalized = [] + for message in messages: + if not isinstance(message, dict): + return None, "Each message must be an object." + content = _normalize_message_content(message.get("content")) + if content is None: + return None, "messages[].content must be a string or an array of content parts." + normalized.append({**message, "content": content}) + return normalized, None + + @manager.route("/openai//chat/completions", methods=["POST"]) # noqa: F821 @login_required @validate_request("model", "messages") @@ -113,6 +150,9 @@ async def openai_chat_completions(chat_id): messages = req.get("messages", []) if len(messages) < 1: return get_error_data_result("You have to provide messages.") + messages, normalize_error = _normalize_openai_messages(messages) + if normalize_error: + return get_error_data_result(normalize_error) if messages[-1]["role"] != "user": return get_error_data_result("The last content of this conversation is not from user.") diff --git a/test/testcases/test_http_api/test_session_management/test_session_sdk_routes_unit.py b/test/testcases/test_http_api/test_session_management/test_session_sdk_routes_unit.py index cdb2736e12..2fa7f824c4 100644 --- a/test/testcases/test_http_api/test_session_management/test_session_sdk_routes_unit.py +++ b/test/testcases/test_http_api/test_session_management/test_session_sdk_routes_unit.py @@ -1045,7 +1045,112 @@ def test_openai_nonstream_branch_unit(monkeypatch): res = _run(inspect.unwrap(module.openai_chat_completions)("chat-1")) assert res["choices"][0]["message"]["content"] == "world" - + + +@pytest.mark.p2 +def test_openai_defaults_to_nonstream_when_stream_omitted_unit(monkeypatch): + """Omitted stream must default to false (OpenAI API compat), not SSE.""" + module = _load_openai_api_module(monkeypatch) + + monkeypatch.setattr(module, "num_tokens_from_string", lambda text: len(text or "")) + monkeypatch.setattr( + module.DialogService, + "query", + lambda **_kwargs: [SimpleNamespace(kb_ids=[], llm_id="chat-model", tenant_id="tenant-1")], + ) + + stream_flags = [] + + async def fake_async_chat(_dia, _msg, stream, **_kwargs): + stream_flags.append(stream) + yield {"answer": "hello", "reference": {}} + + monkeypatch.setattr(module, "async_chat", fake_async_chat) + monkeypatch.setattr( + module, + "get_request_json", + lambda: _AwaitableValue( + { + "model": "model", + "messages": [{"role": "user", "content": "hi"}], + } + ), + ) + + res = _run(inspect.unwrap(module.openai_chat_completions)("chat-1")) + assert stream_flags == [False] + assert isinstance(res, dict) + assert res["object"] == "chat.completion" + assert res["choices"][0]["message"]["content"] == "hello" + + +@pytest.mark.p2 +def test_openai_array_text_content_normalized_unit(monkeypatch): + """OpenAI-style array content with text parts must not crash async_chat.""" + module = _load_openai_api_module(monkeypatch) + + monkeypatch.setattr(module, "num_tokens_from_string", lambda text: len(text or "")) + monkeypatch.setattr( + module.DialogService, + "query", + lambda **_kwargs: [SimpleNamespace(kb_ids=[], llm_id="chat-model", tenant_id="tenant-1")], + ) + + captured_msg = [] + + async def fake_async_chat(_dia, msg, _stream, **_kwargs): + captured_msg.append(msg) + yield {"answer": "ok", "reference": {}} + + monkeypatch.setattr(module, "async_chat", fake_async_chat) + monkeypatch.setattr( + module, + "get_request_json", + lambda: _AwaitableValue( + { + "model": "model", + "stream": False, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Hello"}, + {"type": "text", "text": "World"}, + ], + } + ], + } + ), + ) + + res = _run(inspect.unwrap(module.openai_chat_completions)("chat-1")) + assert captured_msg[0][0]["content"] == "Hello\nWorld" + assert res["choices"][0]["message"]["content"] == "ok" + + +@pytest.mark.p2 +def test_openai_invalid_message_content_type_unit(monkeypatch): + module = _load_openai_api_module(monkeypatch) + + monkeypatch.setattr( + module.DialogService, + "query", + lambda **_kwargs: [SimpleNamespace(kb_ids=[], llm_id="chat-model", tenant_id="tenant-1")], + ) + monkeypatch.setattr( + module, + "get_request_json", + lambda: _AwaitableValue( + { + "model": "model", + "messages": [{"role": "user", "content": 12345}], + } + ), + ) + + res = _run(inspect.unwrap(module.openai_chat_completions)("chat-1")) + assert "messages[].content must be a string or an array of content parts." in res["message"] + @pytest.mark.p2 def test_agents_openai_compatibility_unit(monkeypatch):