193 lines
7.1 KiB
Python
193 lines
7.1 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Tests for ModelInfo class-identity resilience (issues #3301 / #3375).
|
|
|
|
When the same module is loaded via two different import paths (e.g.
|
|
PYTHONPATH=src/ combined with a pip-installed package), Python creates
|
|
two distinct class objects for ModelInfo. Pydantic v2 treats them as
|
|
incompatible types, causing ValidationError on ProviderInfo construction
|
|
and Provider.update_config().
|
|
|
|
These tests simulate that scenario by fabricating a "foreign" ModelInfo
|
|
class and verifying that Provider.get_info() and update_config() still
|
|
succeed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from pineagents.providers.provider import ModelInfo, Provider, ProviderInfo
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers: create a "foreign" ModelInfo that shares the same schema but is
|
|
# a different Python class — exactly what happens under dual module loading.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_FOREIGN_SRC = """\
|
|
from pydantic import BaseModel, Field
|
|
|
|
class ModelInfo(BaseModel):
|
|
id: str = Field(..., description="Model identifier used in API calls")
|
|
name: str = Field(..., description="Human-readable model name")
|
|
supports_multimodal: bool | None = None
|
|
supports_image: bool | None = None
|
|
supports_video: bool | None = None
|
|
probe_source: str | None = None
|
|
generate_kwargs: dict = Field(default_factory=dict)
|
|
"""
|
|
|
|
|
|
def _make_foreign_model_info(model_id: str, name: str):
|
|
"""Create a ModelInfo instance whose class is *not* the canonical one."""
|
|
ns: dict = {}
|
|
exec(_FOREIGN_SRC, ns) # noqa: S102
|
|
ForeignModelInfo = ns["ModelInfo"]
|
|
|
|
# Sanity: the two classes must be distinct
|
|
assert ForeignModelInfo is not ModelInfo
|
|
instance = ForeignModelInfo(id=model_id, name=name)
|
|
assert not isinstance(instance, ModelInfo)
|
|
return instance
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# A minimal concrete Provider for testing (Provider is abstract).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _StubProvider(Provider):
|
|
async def check_connection(self, timeout=5):
|
|
return True, "ok"
|
|
|
|
async def fetch_models(self, timeout=5):
|
|
return []
|
|
|
|
async def check_model_connection(self, model_id, timeout=5):
|
|
return True, "ok"
|
|
|
|
def get_chat_model_instance(self, model_id):
|
|
raise NotImplementedError
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetInfoClassIdentity:
|
|
"""Provider.get_info() must tolerate foreign ModelInfo instances."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_info_with_foreign_models(self):
|
|
"""get_info() should not raise even when self.models contains
|
|
ModelInfo instances from a different class identity."""
|
|
foreign = _make_foreign_model_info("qwen-test", "Qwen Test")
|
|
|
|
provider = _StubProvider(
|
|
id="test-provider",
|
|
name="Test",
|
|
# Use canonical ModelInfo for construction, then swap
|
|
models=[ModelInfo(id="qwen-test", name="Qwen Test")],
|
|
)
|
|
# Simulate class-identity mismatch by replacing with foreign instance
|
|
provider.models = [foreign]
|
|
|
|
# Before the fix this would raise ValidationError
|
|
info = await provider.get_info()
|
|
assert isinstance(info, ProviderInfo)
|
|
assert len(info.models) == 1
|
|
assert info.models[0].id == "qwen-test"
|
|
# The returned ModelInfo must be the canonical class
|
|
assert isinstance(info.models[0], ModelInfo)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_info_with_foreign_extra_models(self):
|
|
"""get_info() should not raise even when self.extra_models contains
|
|
ModelInfo instances from a different class identity."""
|
|
foreign = _make_foreign_model_info("glm-5", "GLM 5")
|
|
|
|
provider = _StubProvider(
|
|
id="test-provider",
|
|
name="Test",
|
|
)
|
|
provider.extra_models = [foreign]
|
|
|
|
info = await provider.get_info()
|
|
assert isinstance(info, ProviderInfo)
|
|
assert len(info.extra_models) == 1
|
|
assert info.extra_models[0].id == "glm-5"
|
|
assert isinstance(info.extra_models[0], ModelInfo)
|
|
|
|
|
|
class TestUpdateConfigClassIdentity:
|
|
"""Provider.update_config() must tolerate foreign ModelInfo instances."""
|
|
|
|
def test_update_config_with_foreign_model_instances(self):
|
|
"""update_config() with foreign ModelInfo instances in extra_models
|
|
should succeed by serializing through dicts."""
|
|
foreign = _make_foreign_model_info("qwen3.5-plus", "Qwen 3.5 Plus")
|
|
|
|
provider = _StubProvider(id="test-provider", name="Test")
|
|
provider.update_config({"extra_models": [foreign]})
|
|
|
|
assert len(provider.extra_models) == 1
|
|
assert provider.extra_models[0].id == "qwen3.5-plus"
|
|
# Must be canonical ModelInfo, not foreign
|
|
assert isinstance(provider.extra_models[0], ModelInfo)
|
|
|
|
def test_update_config_with_dict_models(self):
|
|
"""update_config() with plain dicts should still work normally."""
|
|
provider = _StubProvider(id="test-provider", name="Test")
|
|
provider.update_config(
|
|
{
|
|
"extra_models": [
|
|
{"id": "model-a", "name": "Model A"},
|
|
{"id": "model-b", "name": "Model B"},
|
|
],
|
|
},
|
|
)
|
|
|
|
assert len(provider.extra_models) == 2
|
|
assert all(isinstance(m, ModelInfo) for m in provider.extra_models)
|
|
|
|
def test_update_config_with_canonical_model_instances(self):
|
|
"""update_config() with canonical ModelInfo instances
|
|
must still work."""
|
|
canonical = ModelInfo(id="model-c", name="Model C")
|
|
|
|
provider = _StubProvider(id="test-provider", name="Test")
|
|
provider.update_config({"extra_models": [canonical]})
|
|
|
|
assert len(provider.extra_models) == 1
|
|
assert provider.extra_models[0].id == "model-c"
|
|
assert isinstance(provider.extra_models[0], ModelInfo)
|
|
|
|
|
|
class TestProviderInfoDirectConstruction:
|
|
"""ProviderInfo construction must handle foreign ModelInfo in fields."""
|
|
|
|
def test_provider_info_rejects_foreign_model_directly(self):
|
|
"""Without the get_info() dict-serialization fix, directly passing
|
|
a foreign ModelInfo to ProviderInfo would fail — confirm that the
|
|
failure mode exists so the fix is meaningful."""
|
|
foreign = _make_foreign_model_info("test-model", "Test")
|
|
with pytest.raises(ValidationError):
|
|
ProviderInfo(
|
|
id="p",
|
|
name="P",
|
|
extra_models=[foreign],
|
|
)
|
|
|
|
def test_provider_info_accepts_dicts(self):
|
|
"""ProviderInfo must accept plain dicts for models/extra_models."""
|
|
info = ProviderInfo(
|
|
id="p",
|
|
name="P",
|
|
models=[{"id": "m1", "name": "M1"}],
|
|
extra_models=[{"id": "m2", "name": "M2"}],
|
|
)
|
|
assert info.models[0].id == "m1"
|
|
assert info.extra_models[0].id == "m2"
|