""" Regression tests for scripts/serve_openai_api.py These tests verify OpenAI API compatibility without loading a real model. """ import json import re import sys import os import types # --------------------------------------------------------------------------- # Stub heavy dependencies so importing serve_openai_api doesn't need GPU/model # --------------------------------------------------------------------------- # Provide a minimal torch stub (only attributes actually used at import time) _torch_stub = types.ModuleType("torch") _torch_stub.cuda = types.SimpleNamespace(is_available=lambda: False) _torch_stub.no_grad = lambda: types.SimpleNamespace(__enter__=lambda s: None, __exit__=lambda s, *a: None) _torch_stub.inference_mode = lambda: lambda fn: fn _torch_stub.load = lambda *a, **kw: {} _torch_stub.Tensor = type("Tensor", (), {}) _torch_stub.float32 = "float32" _torch_stub.LongTensor = lambda *a, **kw: None # optim sub-module _optim_stub = types.ModuleType("torch.optim") sys.modules["torch.optim"] = _optim_stub _torch_stub.optim = _optim_stub # nn sub-module with Module and common layers _nn_stub = types.ModuleType("torch.nn") class _ModuleStub: def __init_subclass__(cls, **kw): pass def __init__(self, *a, **kw): pass _nn_stub.Module = _ModuleStub _nn_stub.Linear = type("Linear", (_ModuleStub,), {}) _nn_stub.Embedding = type("Embedding", (_ModuleStub,), {}) _nn_stub.Dropout = type("Dropout", (_ModuleStub,), {}) class _ParameterStub: def __init__(self, *a, **kw): pass _nn_stub.Parameter = _ParameterStub _torch_stub.nn = _nn_stub _nn_func = types.ModuleType("torch.nn.functional") _nn_func.scaled_dot_product_attention = lambda *a, **kw: None _nn_func.cross_entropy = lambda *a, **kw: None _nn_func.softmax = lambda *a, **kw: None _nn_func.silu = lambda *a, **kw: None sys.modules["torch"] = _torch_stub sys.modules["torch.nn"] = _nn_stub sys.modules["torch.nn.functional"] = _nn_func sys.modules.setdefault("torch.distributed", types.ModuleType("torch.distributed")) sys.modules.setdefault("torch.nn.parallel", types.ModuleType("torch.nn.parallel")) sys.modules.setdefault("torch.utils", types.ModuleType("torch.utils")) sys.modules.setdefault("torch.utils.data", types.ModuleType("torch.utils.data")) for mod_name in [ "transformers", "transformers.activations", "transformers.modeling_outputs", "librosa", "soundfile", "numpy", "uvicorn", ]: sys.modules.setdefault(mod_name, types.ModuleType(mod_name)) # Stub transformers classes that are used at module scope _transformers = sys.modules["transformers"] for cls_name in [ "PreTrainedModel", "GenerationMixin", "PretrainedConfig", "AutoTokenizer", "AutoModelForCausalLM", "AutoModel", "AutoModelForSequenceClassification", "TextStreamer", ]: if not hasattr(_transformers, cls_name): setattr(_transformers, cls_name, type(cls_name, (), {})) _mo = sys.modules["transformers.modeling_outputs"] if not hasattr(_mo, "MoeCausalLMOutputWithPast"): setattr(_mo, "MoeCausalLMOutputWithPast", type("MoeCausalLMOutputWithPast", (), {})) _act = sys.modules["transformers.activations"] if not hasattr(_act, "ACT2FN"): setattr(_act, "ACT2FN", {}) # Now import the functions we can test without a live model sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) from scripts.serve_openai_api import parse_response # ============================================================ # Tests for parse_response # ============================================================ class TestParseResponse: """Verify that parse_response correctly separates reasoning, content and tool calls.""" def test_plain_text(self): content, reasoning, tool_calls = parse_response("Hello, world!") assert content == "Hello, world!" assert reasoning is None assert tool_calls is None def test_think_tags_complete(self): text = "Step 1: consider optionsThe answer is 42." content, reasoning, tool_calls = parse_response(text) assert reasoning == "Step 1: consider options" assert content == "The answer is 42." assert tool_calls is None def test_think_tag_no_opening(self): """When only is present (streaming partial), split at the tag.""" text = "I need to think carefullyFinal answer here" content, reasoning, tool_calls = parse_response(text) assert reasoning == "I need to think carefully" assert content == "Final answer here" def test_tool_call_parsed(self): text = 'Sure, I can help. {"name":"get_weather","arguments":{"city":"Tokyo"}}' content, reasoning, tool_calls = parse_response(text) assert "Sure, I can help." in content assert tool_calls is not None assert len(tool_calls) == 1 assert tool_calls[0]["function"]["name"] == "get_weather" args = json.loads(tool_calls[0]["function"]["arguments"]) assert args["city"] == "Tokyo" def test_multiple_tool_calls(self): text = ( '{"name":"a","arguments":{}}' '{"name":"b","arguments":{"x":1}}' ) content, reasoning, tool_calls = parse_response(text) assert tool_calls is not None assert len(tool_calls) == 2 assert tool_calls[0]["function"]["name"] == "a" assert tool_calls[1]["function"]["name"] == "b" def test_invalid_tool_call_json_skipped(self): text = 'not valid jsonThe rest.' content, reasoning, tool_calls = parse_response(text) assert "The rest." in content assert tool_calls is None # Invalid JSON is silently skipped def test_empty_string(self): content, reasoning, tool_calls = parse_response("") assert content == "" assert reasoning is None assert tool_calls is None # ============================================================ # Tests for SSE stream format (structural, no live model) # ============================================================ class TestSSEStreamFormat: """Verify the SSE event stream wrapper produces correct framing.""" def test_done_marker_present(self): """The stream MUST end with 'data: [DONE]\\n\\n' per OpenAI spec.""" # Read the source and verify the [DONE] marker is emitted script_path = os.path.join(os.path.dirname(__file__), "..", "scripts", "serve_openai_api.py") with open(script_path) as f: source = f.read() assert 'data: [DONE]' in source, "Stream must emit 'data: [DONE]' terminator" def test_stream_chunks_have_required_fields(self): """Each stream chunk must include id, object, created, model, choices.""" script_path = os.path.join(os.path.dirname(__file__), "..", "scripts", "serve_openai_api.py") with open(script_path) as f: source = f.read() # Verify _make_chunk builds proper structure with all required fields assert '"id": request_id' in source or '"id":' in source assert '"object": "chat.completion.chunk"' in source assert '"created":' in source assert '"model":' in source def test_cors_middleware_present(self): """CORS middleware must be configured for browser-based clients.""" script_path = os.path.join(os.path.dirname(__file__), "..", "scripts", "serve_openai_api.py") with open(script_path) as f: source = f.read() assert "CORSMiddleware" in source, "CORS middleware required for browser clients" def test_non_stream_uses_max_new_tokens(self): """Non-stream path must use max_new_tokens, not max_length, matching stream behavior.""" script_path = os.path.join(os.path.dirname(__file__), "..", "scripts", "serve_openai_api.py") with open(script_path) as f: source = f.read() # The non-stream generate call should use max_new_tokens # Find the non-stream generate block (after "with torch.no_grad():") non_stream_section = source[source.index("with torch.no_grad()"):] assert "max_new_tokens" in non_stream_section, \ "Non-stream path should use max_new_tokens for consistent behavior with stream path" # max_length should NOT appear in the generate call generate_block = non_stream_section[:non_stream_section.index("answer = tokenizer.decode")] assert "max_length" not in generate_block, \ "Non-stream path should not use max_length (use max_new_tokens instead)" def test_generation_thread_is_daemon(self): """Generation thread must be a daemon so it doesn't block process exit.""" script_path = os.path.join(os.path.dirname(__file__), "..", "scripts", "serve_openai_api.py") with open(script_path) as f: source = f.read() assert "daemon=True" in source, "Generation thread should be daemon to avoid blocking exit" def test_generation_thread_error_propagation(self): """Generation thread errors must be propagated to the consumer, not swallowed.""" script_path = os.path.join(os.path.dirname(__file__), "..", "scripts", "serve_openai_api.py") with open(script_path) as f: source = f.read() # The _generate() function should have a try/except that puts errors on the queue assert "_SENTINEL" in source, "Sentinel-based error propagation required" # ============================================================ # Run with pytest or directly # ============================================================ if __name__ == "__main__": import pytest sys.exit(pytest.main([__file__, "-v"]))