Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 56 additions & 8 deletions backend/protocol_rpc/validators_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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=(",", ":"))
Expand All @@ -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=(",", ":"))
Expand Down
99 changes: 99 additions & 0 deletions tests/unit/test_validators_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading