test_openai_schema.py 4.91 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
105
106
107
108
109
110
111
112
113
114
        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

            # Sometimes guided_grammar is generated to be empty
            # Causing a server error in EBNF grammar parsing
            # https://github.com/vllm-project/vllm/pull/22587#issuecomment-3195253421
            guided_grammar = case.body.get("guided_grammar")

            if guided_grammar == '':
                # Allow None (will be handled as no grammar)
                # But skip empty strings
                return False

115
116
        return True

117
    return strategy.filter(no_invalid_types)
118
119


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

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

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