diff --git a/backend/protocol_rpc/validators_init.py b/backend/protocol_rpc/validators_init.py index 8bbab783e..8ae13a94a 100644 --- a/backend/protocol_rpc/validators_init.py +++ b/backend/protocol_rpc/validators_init.py @@ -17,6 +17,29 @@ class ValidatorConfig: amount: int = 1 +def _validator_identity_key( + provider: str, + model: str, + stake: int, + config: dict | None, + plugin: str | None, + plugin_config: dict | None, +) -> tuple: + """Hashable identity of a validator for config-change detection. + + Includes config/plugin/plugin_config so that changes to provider settings + (e.g. API URLs, plugin options) are detected, not just provider/model/stake. + """ + return ( + provider, + model, + stake, + json.dumps(config or {}, sort_keys=True, separators=(",", ":")), + plugin or "", + json.dumps(plugin_config or {}, sort_keys=True, separators=(",", ":")), + ) + + def _current_config_hash(registry) -> str | None: """Derive a hash from the validators currently in the DB. @@ -25,18 +48,32 @@ def _current_config_hash(registry) -> str | None: all_validators = registry.get_all_validators(include_private_key=False) if not all_validators: return None - # Build a comparable structure: list of (provider, model, stake, count) sorted + # Build a comparable structure: list of (provider, model, stake, config, + # plugin, plugin_config, count) sorted from collections import Counter key_counts = Counter() for v in all_validators: - provider = v.get("provider", "") - model = v.get("model", "") - stake = v.get("stake", 0) - key_counts[(provider, model, stake)] += 1 + key = _validator_identity_key( + v.get("provider", ""), + v.get("model", ""), + v.get("stake", 0), + v.get("config"), + v.get("plugin"), + v.get("plugin_config"), + ) + key_counts[key] += 1 items = [ - {"provider": k[0], "model": k[1], "stake": k[2], "amount": c} + { + "provider": k[0], + "model": k[1], + "stake": k[2], + "config": k[3], + "plugin": k[4], + "plugin_config": k[5], + "amount": c, + } for k, c in sorted(key_counts.items()) ] normalized = json.dumps(items, sort_keys=True, separators=(",", ":")) @@ -51,10 +88,21 @@ def _desired_config_hash(validators_json: str) -> str: key_counts = Counter() for v in data: cfg = ValidatorConfig(**v) - key_counts[(cfg.provider, cfg.model, cfg.stake)] += cfg.amount + key = _validator_identity_key( + cfg.provider, cfg.model, cfg.stake, cfg.config, cfg.plugin, cfg.plugin_config + ) + key_counts[key] += cfg.amount items = [ - {"provider": k[0], "model": k[1], "stake": k[2], "amount": c} + { + "provider": k[0], + "model": k[1], + "stake": k[2], + "config": k[3], + "plugin": k[4], + "plugin_config": k[5], + "amount": c, + } for k, c in sorted(key_counts.items()) ] normalized = json.dumps(items, sort_keys=True, separators=(",", ":")) diff --git a/tests/unit/test_validators_init.py b/tests/unit/test_validators_init.py index fab80dbe6..583da4677 100644 --- a/tests/unit/test_validators_init.py +++ b/tests/unit/test_validators_init.py @@ -40,6 +40,105 @@ async def test_initialize_validators_skips_when_config_unchanged(): mock_registry.replace_all_validators.assert_not_called() +@pytest.mark.asyncio +async def test_initialize_validators_reinitializes_when_plugin_config_changed(): + """Changing only plugin_config/config/plugin must trigger reinitialization. + + Regression test: the config hash previously only covered + (provider, model, stake, amount), so editing e.g. the API URL or + plugin settings in the validators JSON was silently ignored and the + validators were never re-created with the new settings. + """ + mock_db_session = Mock() + mock_registry = AsyncMock() + mock_registry.replace_all_validators = AsyncMock() + # DB validators match desired provider/model/stake, but have an old plugin config + mock_registry.get_all_validators = Mock( + return_value=[ + { + "provider": "openai", + "model": "gpt-4", + "stake": 100, + "config": {}, + "plugin": "openai", + "plugin_config": { + "api_key_env_var": "OPENAIKEY", + "api_url": "https://api.old.example/v1", + }, + }, + ] + ) + validators_manager = SimpleNamespace(registry=mock_registry) + + validators_json = ( + '[{"stake": 100, "provider": "openai", "model": "gpt-4", ' + '"plugin": "openai", "plugin_config": ' + '{"api_key_env_var": "OPENAIKEY", "api_url": "https://api.new.example/v1"}}]' + ) + + with patch( + "backend.database_handler.accounts_manager.AccountsManager" + ) as mock_am_class: + with patch( + "backend.node.create_nodes.providers.get_default_provider_for" + ) as mock_get_provider: + mock_am = Mock() + mock_am.create_new_account.return_value = SimpleNamespace( + address="0xtest", key="privkey" + ) + mock_am_class.return_value = mock_am + + from backend.domain.types import LLMProvider + + mock_get_provider.return_value = LLMProvider( + provider="openai", + model="gpt-4", + config={}, + plugin="openai", + plugin_config={ + "api_key_env_var": "OPENAIKEY", + "api_url": "https://api.new.example/v1", + }, + ) + + await initialize_validators( + validators_json, mock_db_session, validators_manager + ) + + mock_registry.replace_all_validators.assert_called_once() + + +@pytest.mark.asyncio +async def test_config_hash_equal_when_only_config_present_on_both_sides(): + """Identical config/plugin/plugin_config on both sides must NOT reinit.""" + mock_db_session = Mock() + mock_registry = AsyncMock() + plugin_config = {"api_key_env_var": "OPENAIKEY", "api_url": "https://api.example/v1"} + mock_registry.get_all_validators = Mock( + return_value=[ + { + "provider": "openai", + "model": "gpt-4", + "stake": 100, + "config": {"temperature": 0.5}, + "plugin": "openai", + "plugin_config": plugin_config, + }, + ] + ) + validators_manager = SimpleNamespace(registry=mock_registry) + + validators_json = ( + '[{"stake": 100, "provider": "openai", "model": "gpt-4", ' + '"config": {"temperature": 0.5}, "plugin": "openai", "plugin_config": ' + '{"api_key_env_var": "OPENAIKEY", "api_url": "https://api.example/v1"}}]' + ) + + await initialize_validators(validators_json, mock_db_session, validators_manager) + + mock_registry.replace_all_validators.assert_not_called() + + @pytest.mark.asyncio async def test_initialize_validators_reinitializes_when_config_changed(): """If DB validators differ from desired config, reinitialize."""