diff --git a/src/mock_provider/schema_gen.py b/src/mock_provider/schema_gen.py index 36bcf4ae..45c523b0 100644 --- a/src/mock_provider/schema_gen.py +++ b/src/mock_provider/schema_gen.py @@ -31,6 +31,14 @@ from src.mock_provider.coerce import as_dict, as_int, as_list, as_str # inside conclusions, so a $ref cycle is normal input, not a malformed schema. MAX_DEPTH = 6 +# Absolute cap. Past MAX_DEPTH a cycle is expected to terminate on a `default` +# or a nullable/optional branch; a required, non-nullable self-reference has +# neither and would recurse until Python raises RecursionError. Degrading to an +# empty container may violate the schema, but a mock must not turn its own +# defect into a 500. Set well clear of MAX_DEPTH so no schema that terminates +# on its own ever reaches it. +HARD_MAX_DEPTH = MAX_DEPTH * 4 + _WORDS = ( "synthetic", "placeholder", @@ -171,9 +179,14 @@ def _generate( return _generate(_pick_branch(branches, root, depth), root, path, depth) kind = _infer_type(resolved) + # Only the two recursive kinds need the absolute cap; scalars terminate. if kind == "object": + if depth >= HARD_MAX_DEPTH: + return {} return _generate_object(resolved, root, path, depth) if kind == "array": + if depth >= HARD_MAX_DEPTH: + return [] return _generate_array(resolved, root, path, depth) if kind == "integer": return _bounded_int(resolved, path) @@ -264,14 +277,15 @@ def _generate_array( count = min(count, max_items) if depth >= MAX_DEPTH: count = min_items or 0 - if items is None: - return prefix - # `items` describes the positions after the prefix, so only the shortfall is - # filled homogeneously. + # filled. With `items` absent those positions are unconstrained rather than + # disallowed: an empty schema stands in, and the target drops to whatever + # minItems demands, so a bare `{"type": "array"}` still generates nothing. + trailing = items if items is not None else {} + target = count if items is not None else min(count, min_items or 0) return prefix + [ - _generate(items, root, f"{path}[{len(prefix) + i}]", depth + 1) - for i in range(max(0, count - len(prefix))) + _generate(trailing, root, f"{path}[{len(prefix) + i}]", depth + 1) + for i in range(max(0, target - len(prefix))) ] @@ -320,9 +334,37 @@ def _bounded_int(schema: dict[str, Any], path: str) -> int: if low is not None and high is not None: span = high - low - return low + (_seed(path) % (span + 1) if span > 0 else 0) - if low is not None: - return low + (_seed(path) % 8) - if high is not None: - return high - (_seed(path) % 8) - return _seed(path) % 100 + value = low + (_seed(path) % (span + 1) if span > 0 else 0) + elif low is not None: + value = low + (_seed(path) % 8) + elif high is not None: + value = high - (_seed(path) % 8) + else: + value = _seed(path) % 100 + + return _snap_to_multiple(value, as_int(schema.get("multipleOf")), low, high) + + +def _snap_to_multiple( + value: int, multiple: int | None, low: int | None, high: int | None +) -> int: + """Move ``value`` onto a multiple of ``multiple``, staying within bounds. + + A fractional ``multipleOf`` is ignored: ``as_int`` rejects it, and honouring + it would mean returning a non-integer from an ``integer`` schema. + """ + if multiple is None or multiple <= 0: + return value + + # Floor division, so a negative value snaps down to the next multiple below. + snapped = (value // multiple) * multiple + if low is not None and snapped < low: + snapped = -(-low // multiple) * multiple # smallest multiple >= low + if high is not None and snapped > high: + snapped = (high // multiple) * multiple # largest multiple <= high + + # No multiple exists in the window, so the schema is unsatisfiable. An + # in-range value breaks the constraint the caller is less likely to check. + if (low is not None and snapped < low) or (high is not None and snapped > high): + return value + return snapped diff --git a/src/mock_provider/schemas.py b/src/mock_provider/schemas.py index e39fbc11..0135a1e5 100644 --- a/src/mock_provider/schemas.py +++ b/src/mock_provider/schemas.py @@ -20,7 +20,7 @@ from __future__ import annotations from typing import Annotated, Any, ClassVar, Literal -from pydantic import BaseModel, ConfigDict, Field, StrictInt +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt class MockRequest(BaseModel): @@ -38,9 +38,10 @@ class ChatMessage(MockRequest): class StreamOptions(MockRequest): # Typed rather than left as a dict because the usage chunk is conditional on - # it: `{"include_usage": "yes"}` must fail like the real API does rather than - # read as truthy and emit a chunk the caller never asked for. - include_usage: bool = False + # it. StrictBool for the same reason `dimensions` is StrictInt: plain `bool` + # coerces "yes"/"on"/"true"/"1", so a string would quietly decide the shape + # of the stream instead of failing the way the real API does. + include_usage: StrictBool = False class ChatCompletionRequest(MockRequest): @@ -48,7 +49,10 @@ class ChatCompletionRequest(MockRequest): messages: list[ChatMessage] = [] response_format: dict[str, Any] | None = None tools: list[dict[str, Any]] | None = None - stream: bool = False + # StrictBool because this one field decides between two response *shapes* — + # a JSON body or an SSE stream — so coercing a string here is the difference + # between a working client and one that hangs waiting for events. + stream: StrictBool = False stream_options: StreamOptions | None = None diff --git a/tests/mock_provider/test_mock_provider.py b/tests/mock_provider/test_mock_provider.py index 1104d71b..ade916a0 100644 --- a/tests/mock_provider/test_mock_provider.py +++ b/tests/mock_provider/test_mock_provider.py @@ -20,9 +20,10 @@ import pytest from fastapi.testclient import TestClient from pydantic import BaseModel, Field +from src.mock_provider.coerce import as_dict from src.mock_provider.embeddings import content_to_embedding from src.mock_provider.main import app -from src.mock_provider.schema_gen import generate +from src.mock_provider.schema_gen import HARD_MAX_DEPTH, MAX_DEPTH, generate from src.utils.representation import PromptRepresentation # A $ref/$defs schema, which is what Pydantic emits for any nested model and the @@ -313,6 +314,113 @@ def test_prefix_items_are_followed_by_homogeneous_items() -> None: assert all(isinstance(value, bool) for value in result[2:]) +def test_min_items_is_met_when_items_is_omitted() -> None: + """Absent `items` leaves trailing positions unconstrained, not disallowed.""" + schema: dict[str, Any] = { + "type": "array", + "prefixItems": [{"type": "string"}], + "minItems": 3, + } + result = generate(schema) + + assert len(result) == 3 + assert isinstance(result[0], str) + + +@pytest.mark.parametrize( + ("constraints", "multiple"), + [ + ({"minimum": 0, "maximum": 100, "multipleOf": 10}, 10), + ({"minimum": 7, "maximum": 9, "multipleOf": 4}, 4), + ({"minimum": -100, "maximum": 0, "multipleOf": 25}, 25), + ({"minimum": 5, "multipleOf": 3}, 3), + ({"maximum": -5, "multipleOf": 3}, 3), + ({"multipleOf": 6}, 6), + ], +) +def test_multiple_of_is_honoured_within_bounds( + constraints: dict[str, Any], multiple: int +) -> None: + """Path-seeded values land off the multiple unless snapped back onto it.""" + low = constraints.get("minimum") + high = constraints.get("maximum") + + # Several paths, because a single one can satisfy the constraint by luck. + for index in range(12): + schema: dict[str, Any] = { + "type": "object", + "properties": {f"f{index}": {"type": "integer", **constraints}}, + "required": [f"f{index}"], + } + value = generate(schema)[f"f{index}"] + + assert value % multiple == 0, f"{value} is not a multiple of {multiple}" + if low is not None: + assert value >= low + if high is not None: + assert value <= high + + +def test_unsatisfiable_multiple_of_stays_within_bounds() -> None: + """No multiple of 10 lies in [3, 7], so the bounds win over the multiple.""" + schema: dict[str, Any] = { + "type": "integer", + "minimum": 3, + "maximum": 7, + "multipleOf": 10, + } + result = generate(schema) + assert 3 <= result <= 7 + + +def test_required_recursive_ref_terminates_instead_of_overflowing() -> None: + """A required, non-nullable cycle has no `default` or null branch to stop on. + + MAX_DEPTH alone does not save it — `_generate_object` keeps descending into + required properties — so the absolute cap has to. + """ + schema: dict[str, Any] = { + "$defs": { + "Node": { + "type": "object", + "properties": {"child": {"$ref": "#/$defs/Node"}}, + "required": ["child"], + } + }, + "$ref": "#/$defs/Node", + } + node = as_dict(generate(schema)) + + depth = 0 + # The cap returns {}, so an empty dict is the terminator. + while node: + node = as_dict(node["child"]) + depth += 1 + assert depth <= HARD_MAX_DEPTH, "absolute depth cap did not hold" + assert depth > MAX_DEPTH, "should descend past the soft cap before stopping" + + +def test_required_recursive_array_terminates_instead_of_overflowing() -> None: + """minItems >= 1 keeps `_generate_array` from emptying out at the soft cap.""" + schema: dict[str, Any] = { + "$defs": { + "Node": { + "type": "object", + "properties": { + "kids": { + "type": "array", + "items": {"$ref": "#/$defs/Node"}, + "minItems": 1, + } + }, + "required": ["kids"], + } + }, + "$ref": "#/$defs/Node", + } + generate(schema) # must not raise RecursionError + + def test_generation_is_stable_across_calls() -> None: assert generate(PROBE_SCHEMA) == generate(PROBE_SCHEMA) @@ -596,15 +704,37 @@ def test_stream_without_include_usage_emits_no_usage_chunk( assert "[mock]" in content -def test_non_boolean_include_usage_is_rejected(client: TestClient) -> None: - """The usage chunk is conditional on this, so a wrong type must 400.""" +@pytest.mark.parametrize("value", ["definitely", "yes", "on", "true", "1", 1]) +def test_non_boolean_include_usage_is_rejected(client: TestClient, value: Any) -> None: + """The usage chunk is conditional on this, so a wrong type must 400. + + The truthy strings matter more than the nonsense one: plain `bool` coerces + "yes"/"on"/"true"/"1", so without StrictBool a string would silently decide + whether the stream carries usage. + """ response = client.post( "/v1/chat/completions", json={ "model": "mock-model", "messages": [{"role": "user", "content": "x"}], "stream": True, - "stream_options": {"include_usage": "definitely"}, + "stream_options": {"include_usage": value}, + }, + ) + + assert response.status_code == 400 + assert response.json()["error"]["type"] == "invalid_request_error" + + +@pytest.mark.parametrize("value", ["yes", "true", "1", 1]) +def test_non_boolean_stream_is_rejected(client: TestClient, value: Any) -> None: + """`stream` picks between a JSON body and an SSE stream, so it must be exact.""" + response = client.post( + "/v1/chat/completions", + json={ + "model": "mock-model", + "messages": [{"role": "user", "content": "x"}], + "stream": value, }, )