fix(tests): structural path matching for interactions compliance; xfail schema tests

The Google Interactions compliance suite fetches the live spec from
ai.google.dev, which recently restructured:

- the id placeholder is now named {interactionsId}, but the endpoint
  existence matchers looked for the literal "{id}" and never matched,
  even though LiteLLM implements GET and DELETE
  /interactions/{interaction_id}. Match paths structurally (segment
  shape + method) instead of by placeholder name.
- CreateModelInteractionParams was removed; the POST /interactions
  request body is now an AgentInteraction | ModelInteraction oneOf.
  The two schema-compliance tests still assert the old component, so
  mark them xfail (non-strict) with that reason until the suite is
  redesigned against the new component layout.

With this, the interactions compliance suite runs green against the
live spec again (11 passed, 2 xfailed).
This commit is contained in:
thementalcoding 2026-09-29 15:54:28 +02:00
parent 684a1edd44
commit 8fe9eb9b2b

View file

@ -32,8 +32,7 @@ def _load_openapi_spec_dict() -> Dict[str, Any]:
return response.json()
except Exception as e: # pragma: no cover - defensive, env-dependent
pytest.skip(
f"Skipping Google Interactions OpenAPI compliance tests - "
f"unable to load spec from {OPENAPI_SPEC_URL}: {e}"
f"Skipping Google Interactions OpenAPI compliance tests - unable to load spec from {OPENAPI_SPEC_URL}: {e}"
)
@ -44,6 +43,27 @@ def _declared_type_value(variant_schema: Dict[str, Any]) -> Any:
return type_property.get("const") or (enum_values[0] if len(enum_values) == 1 else None)
def _find_interaction_path(spec_paths: Dict[str, Any], method: str) -> str | None:
"""
Find the interactions path carrying `method`, regardless of what Google
names the id placeholder (both `{id}` and `{interactionsId}` have appeared
in this spec).
"""
method = method.lower()
for path, methods in spec_paths.items():
if "interactions" not in path or method not in {m.lower() for m in methods}:
continue
segments = [s for s in path.split("/") if s]
if method == "post":
if path.rstrip("/").endswith("/interactions"):
return path # create
if segments and segments[-1] == "cancel":
return path # cancel
elif segments and segments[-1].startswith("{") and segments[-1].endswith("}"):
return path # get / delete by id
return None
@pytest.fixture(scope="module")
def spec_dict() -> Dict[str, Any]:
"""Load raw spec dict for manual validation."""
@ -59,6 +79,15 @@ def openapi_spec(spec_dict: Dict[str, Any]) -> OpenAPI:
class TestRequestCompliance:
"""Tests that our request bodies match the OpenAPI spec."""
@pytest.mark.xfail(
reason=(
"Google restructured the live spec: CreateModelInteractionParams was "
"replaced by an AgentInteraction | ModelInteraction request body oneOf, "
"so the schema assertions below no longer match. The suite needs a "
"redesign against the new component layout."
),
strict=False,
)
def test_create_model_interaction_request_schema(self, spec_dict):
"""Verify CreateModelInteractionParams schema fields."""
schema = spec_dict["components"]["schemas"]["CreateModelInteractionParams"]
@ -86,6 +115,14 @@ class TestRequestCompliance:
assert field in spec_properties, f"Field '{field}' not in OpenAPI spec"
print(f"✓ Field '{field}' exists in spec")
@pytest.mark.xfail(
reason=(
"Google restructured the live spec: the input schema moved with the "
"CreateModelInteractionParams removal (now AgentInteraction | "
"ModelInteraction). See test_create_model_interaction_request_schema."
),
strict=False,
)
def test_input_types_match_spec(self, spec_dict):
"""Verify input field supports string, Content, Content[], Turn[]."""
schema = spec_dict["components"]["schemas"]["CreateModelInteractionParams"]
@ -125,22 +162,18 @@ class TestRequestCompliance:
discriminator = content_schema.get("discriminator")
if discriminator is not None:
assert (
discriminator.get("propertyName") == "type"
), f"Content is discriminated on {discriminator.get('propertyName')!r}, not 'type'"
assert discriminator.get("propertyName") == "type", (
f"Content is discriminated on {discriminator.get('propertyName')!r}, not 'type'"
)
variant_names = [
option["$ref"].split("/")[-1]
for option in content_schema.get("oneOf", [])
if "$ref" in option
option["$ref"].split("/")[-1] for option in content_schema.get("oneOf", []) if "$ref" in option
]
assert variant_names, f"Content is not a union of named variants: {content_schema}"
mapping = (discriminator or {}).get("mapping") or {}
type_values = {
variant: mapping_value
for mapping_value, ref in mapping.items()
for variant in [ref.split("/")[-1]]
variant: mapping_value for mapping_value, ref in mapping.items() for variant in [ref.split("/")[-1]]
} or {
variant: _declared_type_value(spec_dict["components"]["schemas"].get(variant, {}))
for variant in variant_names
@ -191,7 +224,9 @@ class TestRequestCompliance:
for option in spec_dict["components"]["schemas"]["Step"]["oneOf"]
if "$ref" in option
}
assert {"UserInputStep", "ModelOutputStep"} <= step_variants, f"Step union is missing role steps: {step_variants}"
assert {"UserInputStep", "ModelOutputStep"} <= step_variants, (
f"Step union is missing role steps: {step_variants}"
)
for step_name, type_value in [("UserInputStep", "user_input"), ("ModelOutputStep", "model_output")]:
step_schema = spec_dict["components"]["schemas"][step_name]
@ -261,9 +296,7 @@ class TestResponseCompliance:
expected_fields = ["total_input_tokens", "total_output_tokens", "total_tokens"]
for field in expected_fields:
assert (
field in usage_schema["properties"]
), f"Usage field '{field}' not in spec"
assert field in usage_schema["properties"], f"Usage field '{field}' not in spec"
print(f"✓ Usage field '{field}' exists")
@ -282,9 +315,7 @@ class TestToolsCompliance:
"""Verify FunctionDeclaration schema for function tools."""
if "FunctionDeclaration" in spec_dict["components"]["schemas"]:
func_schema = spec_dict["components"]["schemas"]["FunctionDeclaration"]
assert "name" in func_schema.get(
"properties", {}
) or "name" in func_schema.get("required", [])
assert "name" in func_schema.get("properties", {}) or "name" in func_schema.get("required", [])
print("✓ FunctionDeclaration schema found")
else:
print("⚠ FunctionDeclaration schema not found (may be nested)")
@ -309,27 +340,13 @@ class TestEndpointCompliance:
def test_get_endpoint_exists(self, spec_dict):
"""Verify GET /interactions/{id} endpoint exists."""
paths = spec_dict["paths"]
get_path = None
for path, methods in paths.items():
if "{id}" in path and "interactions" in path and "get" in methods:
get_path = path
break
get_path = _find_interaction_path(spec_dict["paths"], "get")
assert get_path is not None, "GET /interactions/{id} endpoint not found"
print(f"✓ Get endpoint: GET {get_path}")
def test_delete_endpoint_exists(self, spec_dict):
"""Verify DELETE /interactions/{id} endpoint exists."""
paths = spec_dict["paths"]
delete_path = None
for path, methods in paths.items():
if "{id}" in path and "interactions" in path and "delete" in methods:
delete_path = path
break
delete_path = _find_interaction_path(spec_dict["paths"], "delete")
assert delete_path is not None, "DELETE /interactions/{id} endpoint not found"
print(f"✓ Delete endpoint: DELETE {delete_path}")
@ -350,6 +367,4 @@ if __name__ == "__main__":
if method in ["get", "post", "delete", "put", "patch"]:
print(f" {method.upper()} {path}")
print(
f"\nSchemas: {list(spec.get('components', {}).get('schemas', {}).keys())[:10]}..."
)
print(f"\nSchemas: {list(spec.get('components', {}).get('schemas', {}).keys())[:10]}...")