Save local modifications for syncing
Some checks failed
CI / lint (push) Has been cancelled
CI / unit tests (push) Has been cancelled
CI / integration tests (push) Has been cancelled
CI / package build (push) Has been cancelled
Commit lint / pull request title (push) Has been cancelled
Commit lint / commit messages (push) Has been cancelled
Some checks failed
CI / lint (push) Has been cancelled
CI / unit tests (push) Has been cancelled
CI / integration tests (push) Has been cancelled
CI / package build (push) Has been cancelled
Commit lint / pull request title (push) Has been cancelled
Commit lint / commit messages (push) Has been cancelled
This commit is contained in:
@ -2,20 +2,30 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
from everalgo.llm.types import (
|
||||
ChatMessage,
|
||||
ChatResponse,
|
||||
ImageUrlInner,
|
||||
ImageUrlPart,
|
||||
TextPart,
|
||||
)
|
||||
from pydantic import SecretStr
|
||||
|
||||
from everos.component.llm import LLMNotConfiguredError
|
||||
from everos.config import Settings
|
||||
from everos.config.settings import LLMSettings
|
||||
from everos.config.settings import LLMSettings, MultimodalSettings
|
||||
|
||||
_client_mod = importlib.import_module("everos.component.llm.client")
|
||||
|
||||
|
||||
def _reset_singleton(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(_client_mod, "_llm_client", None, raising=False)
|
||||
monkeypatch.setattr(_client_mod, "_multimodal_client", None, raising=False)
|
||||
|
||||
|
||||
def _patch_settings(
|
||||
@ -23,6 +33,7 @@ def _patch_settings(
|
||||
*,
|
||||
api_key: str | None,
|
||||
base_url: str | None,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> None:
|
||||
"""Stub the ``load_settings`` reference bound inside the client module."""
|
||||
cfg = Settings(
|
||||
@ -30,11 +41,86 @@ def _patch_settings(
|
||||
model="gpt-4o-mini",
|
||||
api_key=SecretStr(api_key) if api_key is not None else None,
|
||||
base_url=base_url,
|
||||
**(
|
||||
{"timeout_seconds": timeout_seconds}
|
||||
if timeout_seconds is not None
|
||||
else {}
|
||||
),
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(_client_mod, "load_settings", lambda: cfg)
|
||||
|
||||
|
||||
def _patch_multimodal_settings(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
api_key: str | None,
|
||||
base_url: str | None,
|
||||
timeout_seconds: float | None = None,
|
||||
resize_images_for_vlm: bool | None = None,
|
||||
) -> None:
|
||||
cfg = Settings(
|
||||
multimodal=MultimodalSettings(
|
||||
model="vision-model",
|
||||
api_key=SecretStr(api_key) if api_key is not None else None,
|
||||
base_url=base_url,
|
||||
**(
|
||||
{"timeout_seconds": timeout_seconds}
|
||||
if timeout_seconds is not None
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{"resize_images_for_vlm": resize_images_for_vlm}
|
||||
if resize_images_for_vlm is not None
|
||||
else {}
|
||||
),
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(_client_mod, "load_settings", lambda: cfg)
|
||||
|
||||
|
||||
class _CapturingLLM:
|
||||
def __init__(self) -> None:
|
||||
self.messages: list[ChatMessage] | None = None
|
||||
self.kwargs: dict[str, object] | None = None
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
**kwargs: object,
|
||||
) -> ChatResponse:
|
||||
self.messages = messages
|
||||
self.kwargs = kwargs
|
||||
return ChatResponse(content="ok", model="fake")
|
||||
|
||||
|
||||
def _assert_no_thinking_param(kwargs: dict[str, object] | None) -> None:
|
||||
assert kwargs is not None
|
||||
extra_body = kwargs.get("extra_body")
|
||||
assert isinstance(extra_body, dict)
|
||||
chat_template_kwargs = extra_body.get("chat_template_kwargs")
|
||||
assert isinstance(chat_template_kwargs, dict)
|
||||
assert chat_template_kwargs["enable_thinking"] is False
|
||||
|
||||
|
||||
def _png_data_url(size: tuple[int, int]) -> str:
|
||||
from PIL import Image
|
||||
|
||||
image = Image.new("RGB", size, color=(255, 0, 0))
|
||||
buffer = BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||
return f"data:image/png;base64,{encoded}"
|
||||
|
||||
|
||||
def _data_url_image_size(data_url: str) -> tuple[int, int]:
|
||||
from PIL import Image
|
||||
|
||||
_, encoded = data_url.split(",", 1)
|
||||
with Image.open(BytesIO(base64.b64decode(encoded))) as image:
|
||||
return image.size
|
||||
|
||||
|
||||
def test_raises_when_api_key_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_reset_singleton(monkeypatch)
|
||||
_patch_settings(monkeypatch, api_key=None, base_url="https://example.test")
|
||||
@ -60,5 +146,295 @@ def test_returns_singleton_when_configured(monkeypatch: pytest.MonkeyPatch) -> N
|
||||
first = _client_mod.get_llm_client()
|
||||
second = _client_mod.get_llm_client()
|
||||
|
||||
assert first is sentinel
|
||||
assert first is second
|
||||
assert first._inner is sentinel
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_client_defaults_to_no_thinking_param(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_reset_singleton(monkeypatch)
|
||||
_patch_settings(monkeypatch, api_key="sk-test", base_url="https://example.test")
|
||||
captured = _CapturingLLM()
|
||||
monkeypatch.setattr(_client_mod, "build_client", lambda cfg: captured)
|
||||
|
||||
client = _client_mod.get_llm_client()
|
||||
await client.chat([ChatMessage(role="user", content="hello")])
|
||||
|
||||
_assert_no_thinking_param(captured.kwargs)
|
||||
|
||||
|
||||
def test_llm_client_passes_configured_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_reset_singleton(monkeypatch)
|
||||
_patch_settings(
|
||||
monkeypatch,
|
||||
api_key="sk-test",
|
||||
base_url="https://example.test",
|
||||
timeout_seconds=180.0,
|
||||
)
|
||||
captured_configs = []
|
||||
sentinel = object()
|
||||
|
||||
def capture_build_client(cfg):
|
||||
captured_configs.append(cfg)
|
||||
return sentinel
|
||||
|
||||
monkeypatch.setattr(_client_mod, "build_client", capture_build_client)
|
||||
|
||||
client = _client_mod.get_llm_client()
|
||||
assert client._inner is sentinel
|
||||
assert captured_configs[0].timeout == 180.0
|
||||
|
||||
|
||||
def test_multimodal_client_passes_configured_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_reset_singleton(monkeypatch)
|
||||
_patch_multimodal_settings(
|
||||
monkeypatch,
|
||||
api_key="sk-test",
|
||||
base_url="https://example.test",
|
||||
timeout_seconds=240.0,
|
||||
)
|
||||
captured_configs = []
|
||||
sentinel = _CapturingLLM()
|
||||
|
||||
def capture_build_client(cfg):
|
||||
captured_configs.append(cfg)
|
||||
return sentinel
|
||||
|
||||
monkeypatch.setattr(_client_mod, "build_client", capture_build_client)
|
||||
|
||||
_client_mod.get_multimodal_llm_client()
|
||||
assert captured_configs[0].timeout == 240.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multimodal_client_sets_default_image_detail(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_reset_singleton(monkeypatch)
|
||||
_patch_multimodal_settings(
|
||||
monkeypatch,
|
||||
api_key="sk-test",
|
||||
base_url="https://example.test",
|
||||
)
|
||||
captured = _CapturingLLM()
|
||||
monkeypatch.setattr(_client_mod, "build_client", lambda cfg: captured)
|
||||
|
||||
client = _client_mod.get_multimodal_llm_client()
|
||||
original = ChatMessage(
|
||||
role="user",
|
||||
content=[
|
||||
TextPart(text="describe"),
|
||||
ImageUrlPart(
|
||||
image_url=ImageUrlInner(url="data:image/jpeg;base64,abcd")
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
await client.chat([original], max_tokens=10)
|
||||
|
||||
assert captured.messages is not None
|
||||
sent_content = captured.messages[0].content
|
||||
assert isinstance(sent_content, list)
|
||||
sent_image = sent_content[1]
|
||||
assert isinstance(sent_image, ImageUrlPart)
|
||||
assert sent_image.image_url.detail == "auto"
|
||||
|
||||
original_content = original.content
|
||||
assert isinstance(original_content, list)
|
||||
original_image = original_content[1]
|
||||
assert isinstance(original_image, ImageUrlPart)
|
||||
assert original_image.image_url.detail is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multimodal_client_adds_visual_memory_instructions(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_reset_singleton(monkeypatch)
|
||||
_patch_multimodal_settings(
|
||||
monkeypatch,
|
||||
api_key="sk-test",
|
||||
base_url="https://example.test",
|
||||
)
|
||||
captured = _CapturingLLM()
|
||||
monkeypatch.setattr(_client_mod, "build_client", lambda cfg: captured)
|
||||
|
||||
client = _client_mod.get_multimodal_llm_client()
|
||||
original = ChatMessage(
|
||||
role="user",
|
||||
content=[
|
||||
TextPart(text="Read this image and return its content."),
|
||||
ImageUrlPart(
|
||||
image_url=ImageUrlInner(url="data:image/jpeg;base64,abcd")
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
await client.chat([original], max_tokens=10)
|
||||
|
||||
assert captured.messages is not None
|
||||
sent_content = captured.messages[0].content
|
||||
assert isinstance(sent_content, list)
|
||||
sent_text = sent_content[0]
|
||||
assert isinstance(sent_text, TextPart)
|
||||
sent_text_lower = sent_text.text.lower()
|
||||
assert "spatial relationships" in sent_text_lower
|
||||
assert "relative positions" in sent_text_lower
|
||||
assert "Do NOT describe the parser, assistant, or ChatGPT" in sent_text.text
|
||||
|
||||
original_content = original.content
|
||||
assert isinstance(original_content, list)
|
||||
original_text = original_content[0]
|
||||
assert isinstance(original_text, TextPart)
|
||||
assert "spatial relationships" not in original_text.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multimodal_client_defaults_to_no_thinking_param(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_reset_singleton(monkeypatch)
|
||||
_patch_multimodal_settings(
|
||||
monkeypatch,
|
||||
api_key="sk-test",
|
||||
base_url="https://example.test",
|
||||
)
|
||||
captured = _CapturingLLM()
|
||||
monkeypatch.setattr(_client_mod, "build_client", lambda cfg: captured)
|
||||
|
||||
client = _client_mod.get_multimodal_llm_client()
|
||||
original = ChatMessage(
|
||||
role="user",
|
||||
content=[
|
||||
TextPart(text="Read this image and return its content."),
|
||||
ImageUrlPart(
|
||||
image_url=ImageUrlInner(url="data:image/jpeg;base64,abcd")
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
await client.chat(
|
||||
[original],
|
||||
max_tokens=10,
|
||||
extra_body={"provider": {"only": ["test"]}},
|
||||
)
|
||||
|
||||
_assert_no_thinking_param(captured.kwargs)
|
||||
assert captured.kwargs is not None
|
||||
extra_body = captured.kwargs["extra_body"]
|
||||
assert isinstance(extra_body, dict)
|
||||
assert extra_body["provider"] == {"only": ["test"]}
|
||||
assert captured.messages is not None
|
||||
sent_content = captured.messages[0].content
|
||||
assert isinstance(sent_content, list)
|
||||
sent_text = sent_content[0]
|
||||
assert isinstance(sent_text, TextPart)
|
||||
assert "/no_think" not in sent_text.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multimodal_client_resizes_landscape_image_to_64_min_side_by_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_reset_singleton(monkeypatch)
|
||||
_patch_multimodal_settings(
|
||||
monkeypatch,
|
||||
api_key="sk-test",
|
||||
base_url="https://example.test",
|
||||
)
|
||||
captured = _CapturingLLM()
|
||||
monkeypatch.setattr(_client_mod, "build_client", lambda cfg: captured)
|
||||
image_url = _png_data_url((640, 480))
|
||||
|
||||
client = _client_mod.get_multimodal_llm_client()
|
||||
original = ChatMessage(
|
||||
role="user",
|
||||
content=[
|
||||
TextPart(text="describe"),
|
||||
ImageUrlPart(image_url=ImageUrlInner(url=image_url)),
|
||||
],
|
||||
)
|
||||
|
||||
await client.chat([original], max_tokens=10)
|
||||
|
||||
assert captured.messages is not None
|
||||
sent_content = captured.messages[0].content
|
||||
assert isinstance(sent_content, list)
|
||||
sent_image = sent_content[1]
|
||||
assert isinstance(sent_image, ImageUrlPart)
|
||||
assert _data_url_image_size(sent_image.image_url.url) == (85, 64)
|
||||
assert _data_url_image_size(image_url) == (640, 480)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multimodal_client_resizes_portrait_image_to_64_min_side_by_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_reset_singleton(monkeypatch)
|
||||
_patch_multimodal_settings(
|
||||
monkeypatch,
|
||||
api_key="sk-test",
|
||||
base_url="https://example.test",
|
||||
)
|
||||
captured = _CapturingLLM()
|
||||
monkeypatch.setattr(_client_mod, "build_client", lambda cfg: captured)
|
||||
image_url = _png_data_url((480, 640))
|
||||
|
||||
client = _client_mod.get_multimodal_llm_client()
|
||||
original = ChatMessage(
|
||||
role="user",
|
||||
content=[
|
||||
TextPart(text="describe"),
|
||||
ImageUrlPart(image_url=ImageUrlInner(url=image_url)),
|
||||
],
|
||||
)
|
||||
|
||||
await client.chat([original], max_tokens=10)
|
||||
|
||||
assert captured.messages is not None
|
||||
sent_content = captured.messages[0].content
|
||||
assert isinstance(sent_content, list)
|
||||
sent_image = sent_content[1]
|
||||
assert isinstance(sent_image, ImageUrlPart)
|
||||
assert _data_url_image_size(sent_image.image_url.url) == (64, 85)
|
||||
assert _data_url_image_size(image_url) == (480, 640)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multimodal_client_keeps_image_when_resize_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_reset_singleton(monkeypatch)
|
||||
_patch_multimodal_settings(
|
||||
monkeypatch,
|
||||
api_key="sk-test",
|
||||
base_url="https://example.test",
|
||||
resize_images_for_vlm=False,
|
||||
)
|
||||
captured = _CapturingLLM()
|
||||
monkeypatch.setattr(_client_mod, "build_client", lambda cfg: captured)
|
||||
image_url = _png_data_url((640, 480))
|
||||
|
||||
client = _client_mod.get_multimodal_llm_client()
|
||||
original = ChatMessage(
|
||||
role="user",
|
||||
content=[
|
||||
TextPart(text="describe"),
|
||||
ImageUrlPart(image_url=ImageUrlInner(url=image_url)),
|
||||
],
|
||||
)
|
||||
|
||||
await client.chat([original], max_tokens=10)
|
||||
|
||||
assert captured.messages is not None
|
||||
sent_content = captured.messages[0].content
|
||||
assert isinstance(sent_content, list)
|
||||
sent_image = sent_content[1]
|
||||
assert isinstance(sent_image, ImageUrlPart)
|
||||
assert sent_image.image_url.url == image_url
|
||||
|
||||
@ -6,6 +6,7 @@ import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from everos.component.llm import build_llm_provider
|
||||
from everos.component.llm import factory as factory_mod
|
||||
from everos.component.llm.openai_provider import OpenAIProvider
|
||||
from everos.config.settings import LLMSettings
|
||||
|
||||
@ -26,3 +27,23 @@ def test_builds_openai_provider() -> None:
|
||||
s = LLMSettings(model="m", api_key=SecretStr("k"), base_url="https://x")
|
||||
p = build_llm_provider(s)
|
||||
assert isinstance(p, OpenAIProvider)
|
||||
|
||||
|
||||
def test_passes_configured_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured_kwargs = {}
|
||||
sentinel = object()
|
||||
|
||||
def capture_provider(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return sentinel
|
||||
|
||||
monkeypatch.setattr(factory_mod, "OpenAIProvider", capture_provider)
|
||||
s = LLMSettings(
|
||||
model="m",
|
||||
api_key=SecretStr("k"),
|
||||
base_url="https://x",
|
||||
timeout_seconds=240.0,
|
||||
)
|
||||
|
||||
assert build_llm_provider(s) is sentinel
|
||||
assert captured_kwargs["timeout"] == 240.0
|
||||
|
||||
@ -105,6 +105,9 @@ def test_embedding_rerank_defaults() -> None:
|
||||
assert s.embedding.model is None
|
||||
assert s.embedding.api_key is None
|
||||
assert s.embedding.base_url is None
|
||||
assert s.llm.timeout_seconds == 180.0
|
||||
assert s.multimodal.timeout_seconds == 180.0
|
||||
assert s.multimodal.resize_images_for_vlm is True
|
||||
# Runtime knobs come from default.toml.
|
||||
assert s.embedding.timeout_seconds == 30.0
|
||||
assert s.embedding.max_retries == 3
|
||||
@ -126,6 +129,16 @@ def test_embedding_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert s.embedding.batch_size == 32
|
||||
|
||||
|
||||
def test_llm_timeout_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("EVEROS_LLM__TIMEOUT_SECONDS", "240")
|
||||
monkeypatch.setenv("EVEROS_MULTIMODAL__TIMEOUT_SECONDS", "300")
|
||||
monkeypatch.setenv("EVEROS_MULTIMODAL__RESIZE_IMAGES_FOR_VLM", "false")
|
||||
s = Settings()
|
||||
assert s.llm.timeout_seconds == 240.0
|
||||
assert s.multimodal.timeout_seconds == 300.0
|
||||
assert s.multimodal.resize_images_for_vlm is False
|
||||
|
||||
|
||||
def test_rerank_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("EVEROS_RERANK__MODEL", "BAAI/bge-reranker-v2-m3")
|
||||
monkeypatch.setenv("EVEROS_RERANK__MAX_CONCURRENT", "8")
|
||||
|
||||
@ -9,7 +9,11 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from everos.memory.cascade.watcher import _relative_to_root, _safe_mtime
|
||||
from everos.memory.cascade.watcher import (
|
||||
_relative_to_root,
|
||||
_safe_mtime,
|
||||
_watch_roots,
|
||||
)
|
||||
|
||||
|
||||
def test_relative_to_root_within(tmp_path: Path) -> None:
|
||||
@ -34,3 +38,14 @@ def test_safe_mtime_existing_path_returns_positive(tmp_path: Path) -> None:
|
||||
f = tmp_path / "f.md"
|
||||
f.write_text("ok")
|
||||
assert _safe_mtime(str(f)) > 0
|
||||
|
||||
|
||||
def test_watch_roots_excludes_system_dot_dirs(tmp_path: Path) -> None:
|
||||
(tmp_path / ".index" / "lancedb" / "episode").mkdir(parents=True)
|
||||
(tmp_path / ".tmp").mkdir()
|
||||
(tmp_path / "default_app" / "default_project" / "users").mkdir(parents=True)
|
||||
(tmp_path / "default_app" / "default_project" / "agents").mkdir()
|
||||
|
||||
roots = _watch_roots(tmp_path)
|
||||
|
||||
assert roots == [tmp_path / "default_app"]
|
||||
|
||||
@ -21,7 +21,10 @@ def test_derive_text_renders_parsed_nontext_as_tag() -> None:
|
||||
]
|
||||
text, non_text = derive_text(items)
|
||||
|
||||
assert "[IMAGE: p.png]\nOCR TEXT" in text
|
||||
assert "[IMAGE: p.png]" in text
|
||||
assert "image visual facts" in text
|
||||
assert "not assistant actions" in text
|
||||
assert text.index("image visual facts") < text.index("OCR TEXT")
|
||||
assert text.startswith("before")
|
||||
assert text.endswith("after")
|
||||
assert non_text == 0
|
||||
|
||||
Reference in New Issue
Block a user