"""Schema2 tool declarations migrate without silent text-only degradation.""" from copy import deepcopy import pytest from EvoScientist.llm.contracts import EvoRuntimeError from EvoScientist.llm.model_config import EvoModelConfig, convert_v2_to_v3_draft from tests.v3_fixtures import v3_payload def test_schema2_tools_require_explicit_native_route_contract(): # Keep this node identity: the historical non_streaming label was the only # accepted Web tool route contract, not a measured capability or HTTP knob. payload = v3_payload() payload["capability_evidence"] = [] models = payload["providers"]["custom-openai"]["models"] unknown = deepcopy(models[0]) unknown["id"] = "unreferenced-text-model" models.append(unknown) for transport in ("native", "non_streaming"): payload["route_selectors"]["visible-main"]["tool_call_transport"] = transport config = EvoModelConfig.parse(payload, require_evidence=False) parsed = config.providers["custom-openai"].models assert parsed[unknown["id"]].capabilities["tools"] is False assert parsed[unknown["id"]].capabilities["text"] is True assert parsed["model-id"].capabilities["tools"] is True assert parsed["model-id"].capabilities["text"] is True route = config.concrete_routes("visible-main")[0] assert config.route_model(route).capabilities["tools"] is True assert route.tool_call_transport == transport assert config.capability_evidence == {} draft, report = convert_v2_to_v3_draft( payload, target_revision=2, config_identity_key_id="migration-test" ) assert not report.blocking_issues migrated = {m["model_key"]: m for m in draft["providers"][0]["models"]} for key, expected in (("model-id", True), (unknown["id"], False)): assert migrated[key]["capabilities"]["tools"] is expected assert migrated[key]["invocation"]["tool_call_transport"] == ( "native" if expected else "disabled" ) ambiguous = deepcopy(payload) ambiguous["route_selectors"]["visible-main"]["tool_call_transport"] = "streaming" with pytest.raises(EvoRuntimeError, match="schema2 tool capability migration"): EvoModelConfig.parse(ambiguous, require_evidence=False) with pytest.raises(EvoRuntimeError, match="schema2 tool capability migration"): convert_v2_to_v3_draft( ambiguous, target_revision=2, config_identity_key_id="migration-test" ) # Aggregate declarations across routes, independent of insertion order. declared = deepcopy(payload["route_selectors"]["visible-main"]) ambiguous["route_selectors"]["declared"] = declared for reverse in (False, True): if reverse: ambiguous["route_selectors"] = dict( reversed(list(ambiguous["route_selectors"].items())) ) config = EvoModelConfig.parse(ambiguous, require_evidence=False) route = config.concrete_routes("visible-main")[0] assert config.route_model(route).capabilities["tools"] is True # Same model id on another provider must not inherit the declaration. isolated = deepcopy(payload) isolated["providers"]["other"] = deepcopy(payload["providers"]["custom-openai"]) other = { **declared, "provider": "other", "endpoint": "primary", "tool_call_transport": "streaming", } other.pop("endpoint_pool") isolated["route_selectors"]["other-route"] = other config = EvoModelConfig.parse(isolated, require_evidence=False) assert config.providers["other"].models["model-id"].capabilities["tools"] is False for usage in ("main", "title", "fallback"): referenced = deepcopy(isolated) if usage == "main": selectable = referenced["purpose_routes"]["main_agent"]["selectable"] selectable["other"] = "other-route" elif usage == "title": referenced["purpose_routes"]["title"]["default"] = "other-route" else: referenced["tool_protocol_fallbacks"][0]["fallbacks"] = ["other-route"] with pytest.raises(EvoRuntimeError, match="schema2 tool capability migration"): EvoModelConfig.parse(referenced, require_evidence=False) referenced["route_selectors"]["other-route"].update( tool_call_transport="non_streaming" ) config = EvoModelConfig.parse(referenced, require_evidence=False) assert config.providers["other"].models["model-id"].capabilities["tools"] is True other_models = config.providers["other"].models assert other_models[unknown["id"]].capabilities["tools"] is False