test_openai_schema.py 5.05 KB
Newer Older
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
import json
4
5
from typing import Final

6
7
import pytest
import schemathesis
8
from hypothesis import settings
9
10
11
12
13
14
15
16
from schemathesis import GenerationConfig

from ...utils import RemoteOpenAIServer

schemathesis.experimental.OPEN_API_3_1.enable()

MODEL_NAME = "HuggingFaceTB/SmolVLM-256M-Instruct"
MAXIMUM_IMAGES = 2
17
18
DEFAULT_TIMEOUT_SECONDS: Final[int] = 10
LONG_TIMEOUT_SECONDS: Final[int] = 60
19
20
21
22
23


@pytest.fixture(scope="module")
def server():
    args = [
24
        "--runner",
25
26
27
28
29
30
31
32
        "generate",
        "--max-model-len",
        "2048",
        "--max-num-seqs",
        "5",
        "--enforce-eager",
        "--trust-remote-code",
        "--limit-mm-per-prompt",
33
        json.dumps({"image": MAXIMUM_IMAGES}),
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
    ]

    with RemoteOpenAIServer(MODEL_NAME, args) as remote_server:
        yield remote_server


@pytest.fixture(scope="module")
def get_schema(server):
    # avoid generating null (\x00) bytes in strings during test case generation
    return schemathesis.openapi.from_uri(
        f"{server.url_root}/openapi.json",
        generation_config=GenerationConfig(allow_x00=False),
    )


schema = schemathesis.from_pytest_fixture("get_schema")


52
53
54
55
56
@schemathesis.hook
def before_generate_case(context: schemathesis.hooks.HookContext, strategy):
    op = context.operation
    assert op is not None

57
    def no_invalid_types(case: schemathesis.models.Case):
58
        """
59
60
61
62
63
64
65
66
        This filter skips test cases with invalid data that schemathesis
        incorrectly generates due to permissive schema configurations.
        
        1. Skips `POST /tokenize` endpoint cases with `"type": "file"` in 
           message content, which isn't implemented.
        
        2. Skips tool_calls with `"type": "custom"` which schemathesis 
           incorrectly generates instead of the valid `"type": "function"`.
67
68
69

        Example test cases that are skipped:
        curl -X POST -H 'Content-Type: application/json' \
70
            -d '{"messages": [{"content": [{"file": {}, "type": "file"}], "role": "user"}]}' \
71
72
73
            http://localhost:8000/tokenize

        curl -X POST -H 'Content-Type: application/json' \
74
75
            -d '{"messages": [{"role": "assistant", "tool_calls": [{"custom": {"input": "", "name": ""}, "id": "", "type": "custom"}]}]}' \
            http://localhost:8000/v1/chat/completions
76
        """  # noqa: E501
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
        if hasattr(case, "body") and isinstance(case.body, dict):
            if ("messages" in case.body
                    and isinstance(case.body["messages"], list)
                    and len(case.body["messages"]) > 0):

                for message in case.body["messages"]:
                    if not isinstance(message, dict):
                        continue

                    # Check for invalid file type in tokenize endpoint
                    if op.method.lower() == "post" and op.path == "/tokenize":
                        content = message.get("content", [])
                        if (isinstance(content, list) and len(content) > 0
                                and any(
                                    item.get("type") == "file"
                                    for item in content)):
                            return False

                    # Check for invalid tool_calls with non-function types
                    tool_calls = message.get("tool_calls", [])
                    if isinstance(tool_calls, list):
                        for tool_call in tool_calls:
                            if isinstance(tool_call, dict):
                                if tool_call.get("type") != "function":
                                    return False
                                if "custom" in tool_call:
                                    return False

105
            # Sometimes structured_outputs.grammar is generated to be empty
106
107
            # Causing a server error in EBNF grammar parsing
            # https://github.com/vllm-project/vllm/pull/22587#issuecomment-3195253421
108
109
110
            structured_outputs = case.body.get("structured_outputs", {})
            grammar = structured_outputs.get("grammar") if isinstance(
                structured_outputs, dict) else None
111

112
            if grammar == '':
113
114
115
116
                # Allow None (will be handled as no grammar)
                # But skip empty strings
                return False

117
118
        return True

119
    return strategy.filter(no_invalid_types)
120
121


122
123
@schema.parametrize()
@schema.override(headers={"Content-Type": "application/json"})
124
@settings(deadline=LONG_TIMEOUT_SECONDS * 1000)
125
def test_openapi_stateless(case: schemathesis.Case):
126
127
128
129
    key = (
        case.operation.method.upper(),
        case.operation.path,
    )
130
131
132
133
    if case.operation.path.startswith("/v1/responses"):
        # Skip responses API as it is meant to be stateful.
        return

134
135
136
137
138
139
    timeout = {
        # requires a longer timeout
        ("POST", "/v1/chat/completions"):
        LONG_TIMEOUT_SECONDS,
    }.get(key, DEFAULT_TIMEOUT_SECONDS)

140
    #No need to verify SSL certificate for localhost
141
    case.call_and_validate(verify=False, timeout=timeout)