The shared helper's fail-closed behavior (yielding the "" sentinel rather than skipping a well-typed-but-unparseable image_url part) was only tested indirectly through detect_capabilities. Add direct unit tests covering the four input shapes of the shared-helper-unparseable-input-contract plan: common/valid, alternate valid, well-typed-but-unparseable (-> sentinel), and wrong-typed/absent (-> skip). Presence of the image_url key is the type tag: a present-but-malformed payload still yields the sentinel.
169 lines
6.4 KiB
Python
169 lines
6.4 KiB
Python
"""Tests for capabilities.py — request-side capability detection.
|
|
|
|
Each case proves one detection fires and that the others stay False, mirroring
|
|
the "rejects and does not over-reject" shape of test_routing.py.
|
|
"""
|
|
|
|
from capabilities import detect_capabilities, iter_image_url_values
|
|
|
|
|
|
def test_no_parts_yields_no_capabilities():
|
|
caps = detect_capabilities({"messages": [{"content": "plain text"}]})
|
|
assert caps == detect_capabilities({})
|
|
assert not caps.has_images
|
|
assert not caps.require_json_mode
|
|
assert not caps.tools_present
|
|
assert not caps.has_reasoning_request
|
|
|
|
|
|
def test_image_url_part_sets_has_images():
|
|
body = {
|
|
"messages": [
|
|
{"content": [{"type": "image_url", "url": "https://x/img.png"}]}
|
|
]
|
|
}
|
|
assert detect_capabilities(body).has_images is True
|
|
|
|
|
|
def test_a_degenerate_image_url_part_still_counts():
|
|
# No `image_url` key and no bare `url` key -- spec-invalid, but a part
|
|
# that declares itself an image must still count as one rather than
|
|
# silently vanishing because its value can't be resolved. Skipping it
|
|
# would let a malformed part dodge the vision-capability gate.
|
|
body = {"messages": [{"content": [{"type": "image_url"}]}]}
|
|
assert detect_capabilities(body).has_images is True
|
|
|
|
|
|
def test_image_in_an_earlier_message_counts():
|
|
body = {
|
|
"messages": [
|
|
{"content": [{"type": "image_url", "url": "https://x/1.png"}]},
|
|
{"content": "now what do you make of it?"},
|
|
]
|
|
}
|
|
assert detect_capabilities(body).has_images is True
|
|
|
|
|
|
def test_non_image_parts_do_not_trigger():
|
|
body = {
|
|
"messages": [
|
|
{
|
|
"content": [
|
|
{"type": "text", "text": "hello"},
|
|
{"type": "tool_use", "id": "t1"},
|
|
]
|
|
}
|
|
]
|
|
}
|
|
assert detect_capabilities(body).has_images is False
|
|
|
|
|
|
def test_string_content_does_not_trigger_images():
|
|
# A raw base64 string in a text part is not an image_url part.
|
|
body = {"messages": [{"content": "data:image/png;base64,AA=="}]}
|
|
assert detect_capabilities(body).has_images is False
|
|
|
|
|
|
def test_json_object_response_format_sets_require_json_mode():
|
|
body = {"response_format": {"type": "json_object"}}
|
|
assert detect_capabilities(body).require_json_mode is True
|
|
|
|
|
|
def test_json_schema_response_format_sets_require_json_mode():
|
|
body = {"response_format": {"type": "json_schema", "json_schema": {}}}
|
|
assert detect_capabilities(body).require_json_mode is True
|
|
|
|
|
|
def test_text_or_missing_response_format_does_not_require_json_mode():
|
|
assert detect_capabilities({}).require_json_mode is False
|
|
assert detect_capabilities({"response_format": {"type": "text"}}).require_json_mode is False
|
|
|
|
|
|
def test_tools_array_sets_tools_present():
|
|
assert detect_capabilities({"tools": [{"type": "function"}]}).tools_present is True
|
|
# An empty array is no tools at all.
|
|
assert detect_capabilities({"tools": []}).tools_present is False
|
|
|
|
|
|
def test_reasoning_effort_sets_has_reasoning_request():
|
|
assert detect_capabilities({"reasoning_effort": "high"}).has_reasoning_request is True
|
|
|
|
|
|
def test_reasoning_param_sets_has_reasoning_request():
|
|
assert detect_capabilities({"reasoning": {"enabled": True}}).has_reasoning_request is True
|
|
|
|
|
|
# --- iter_image_url_values contract --------------------------------------
|
|
#
|
|
# Pins the shared helper's contract across the four input shapes of
|
|
# code_plans/shared-helper-unparseable-input-contract.md. The point of this
|
|
# test is the same as the contract: the well-typed-but-unparseable shape
|
|
# must YIELD ITS SENTINEL ("") rather than be skipped silently, because
|
|
# skipping drops the part out of the fail-closed image checks at once.
|
|
|
|
|
|
def test_iter_image_url_values_yields_valid_dict_url():
|
|
# Common/valid shape: image_url as a dict carrying `url`.
|
|
messages = [{"content": [{"type": "image_url", "image_url": {"url": "https://x/img.png"}}]}]
|
|
assert list(iter_image_url_values(messages)) == ["https://x/img.png"]
|
|
|
|
|
|
def test_iter_image_url_values_alternate_valid_shapes():
|
|
# Alternate valid shapes the four originals agreed on:
|
|
# image_url as a bare string, and a bare `url` key on the part itself.
|
|
messages = [
|
|
{"content": [{"type": "image_url", "image_url": "https://x/bare.png"}]},
|
|
{"content": [{"type": "image_url", "url": "https://x/barekey.png"}]},
|
|
]
|
|
assert list(iter_image_url_values(messages)) == [
|
|
"https://x/bare.png",
|
|
"https://x/barekey.png",
|
|
]
|
|
|
|
|
|
def test_iter_image_url_values_well_typed_unparseable_yields_sentinel():
|
|
# Well-typed but unparseable: the part declares itself an image_url but
|
|
# carries neither `image_url` nor a bare `url` that resolves. It must
|
|
# still yield SOMETHING (the "" sentinel) rather than vanish, so a
|
|
# degenerate part cannot slip out of the fail-closed checks. This is the
|
|
# shape the plan exists to pin down.
|
|
messages = [{"content": [{"type": "image_url"}]}]
|
|
assert list(iter_image_url_values(messages)) == [""]
|
|
|
|
|
|
def test_iter_image_url_values_wrong_payload_still_yields_sentinel():
|
|
# The `image_url` key is present but its value is not a dict or a string
|
|
# (an int, say). Presence of the key is enough to claim "this is an
|
|
# image"; the payload fails to resolve, so it yields the "" sentinel
|
|
# rather than being skipped -- the same fail-closed contract as a part
|
|
# with no key at all.
|
|
messages = [{"content": [{"type": "image_url", "image_url": 42}]}]
|
|
assert list(iter_image_url_values(messages)) == [""]
|
|
|
|
|
|
def test_iter_image_url_values_skips_wrong_typed_and_absent():
|
|
# Wrong-typed / absent shapes fail the coarse check and are skipped;
|
|
# they contribute nothing and yield nothing.
|
|
messages = [
|
|
"not a dict",
|
|
{"content": "plain string, not a list"},
|
|
{"content": [{"type": "text", "text": "hello"}]},
|
|
{"content": [{"type": "tool_use", "id": "t1"}]},
|
|
]
|
|
assert list(iter_image_url_values(messages)) == []
|
|
|
|
|
|
def test_iter_image_url_values_mixed_shapes_preserve_order():
|
|
# A degenerate part in the middle must still count (as "") and not
|
|
# disturb the ordering of the parts around it.
|
|
messages = [
|
|
{"content": [{"type": "image_url", "url": "https://x/1.png"}]},
|
|
{"content": [{"type": "image_url"}]},
|
|
{"content": [{"type": "image_url", "image_url": {"url": "https://x/2.png"}}]},
|
|
]
|
|
assert list(iter_image_url_values(messages)) == [
|
|
"https://x/1.png",
|
|
"",
|
|
"https://x/2.png",
|
|
]
|