Add configurable LM Studio flushing and LLM retries.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from photools3.llm_session import LlmSession
|
||||
|
||||
|
||||
@@ -84,3 +86,70 @@ def test_llm_session_captions_and_keywords_mocked(test_photo):
|
||||
|
||||
# Verify that the mock was called
|
||||
assert mock_client.chat.completions.create.called
|
||||
|
||||
|
||||
def test_llm_session_retries_from_original_request_without_accumulating_messages():
|
||||
with patch("photools3.llm_session.OpenAI") as mocked_openai:
|
||||
mock_client = MagicMock()
|
||||
mocked_openai.return_value = mock_client
|
||||
mock_model = MagicMock()
|
||||
mock_model.id = "gpt-4o"
|
||||
mock_client.models.list.return_value.data = [mock_model]
|
||||
|
||||
def response(content):
|
||||
message = MagicMock()
|
||||
message.content = content
|
||||
choice = MagicMock()
|
||||
choice.message = message
|
||||
result = MagicMock()
|
||||
result.choices = [choice]
|
||||
return result
|
||||
|
||||
mock_client.chat.completions.create.side_effect = [
|
||||
response('{"wrong": "first"}'),
|
||||
response('{"wrong": "second"}'),
|
||||
response('{"text": "correct"}'),
|
||||
]
|
||||
|
||||
session = LlmSession(system_prompt="You are a helper.")
|
||||
result = session.call(photos=[], prompt="Return JSON")
|
||||
|
||||
assert result.text == "correct"
|
||||
requests = mock_client.chat.completions.create.call_args_list
|
||||
messages_per_attempt = [call.kwargs["messages"] for call in requests]
|
||||
assert [len(messages) for messages in messages_per_attempt] == [2, 3, 3]
|
||||
final_correction = messages_per_attempt[2][-1]["content"]
|
||||
assert '"wrong": "second"' in final_correction
|
||||
assert '"wrong": "first"' not in final_correction
|
||||
|
||||
|
||||
def test_llm_session_uses_configured_retry_count(monkeypatch):
|
||||
monkeypatch.setattr("photools3.llm_session.settings.llm_max_retries", 1)
|
||||
|
||||
with patch("photools3.llm_session.OpenAI") as mocked_openai:
|
||||
mock_client = MagicMock()
|
||||
mocked_openai.return_value = mock_client
|
||||
mock_model = MagicMock()
|
||||
mock_model.id = "gpt-4o"
|
||||
mock_client.models.list.return_value.data = [mock_model]
|
||||
|
||||
def response(content):
|
||||
message = MagicMock()
|
||||
message.content = content
|
||||
choice = MagicMock()
|
||||
choice.message = message
|
||||
result = MagicMock()
|
||||
result.choices = [choice]
|
||||
return result
|
||||
|
||||
mock_client.chat.completions.create.side_effect = [
|
||||
response('{"wrong": "first"}'),
|
||||
response('{"wrong": "second"}'),
|
||||
response('{"text": "would be a third call"}'),
|
||||
]
|
||||
session = LlmSession(system_prompt="You are a helper.")
|
||||
|
||||
with pytest.raises(ValueError, match="after 1 retries"):
|
||||
session.call(photos=[])
|
||||
|
||||
assert mock_client.chat.completions.create.call_count == 2
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from photools3.lm_studio import LMStudioError, LMStudioModelManager
|
||||
|
||||
|
||||
def _http_response(payload: dict):
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.read.return_value = json.dumps(payload).encode("utf-8")
|
||||
return response
|
||||
|
||||
|
||||
def test_reload_model_unloads_and_reloads_preserving_context_config():
|
||||
model_response = {
|
||||
"models": [
|
||||
{
|
||||
"key": "qwen/qwen3-vl-4b",
|
||||
"loaded_instances": [
|
||||
{
|
||||
"id": "qwen/qwen3-vl-4b-instance",
|
||||
"config": {
|
||||
"context_length": 16384,
|
||||
"eval_batch_size": 256,
|
||||
"flash_attention": True,
|
||||
"offload_kv_cache_to_gpu": False,
|
||||
"parallel": 2,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
with patch(
|
||||
"photools3.lm_studio.urlopen",
|
||||
side_effect=[
|
||||
_http_response(model_response),
|
||||
_http_response({"instance_id": "qwen/qwen3-vl-4b-instance"}),
|
||||
_http_response({"instance_id": "qwen/qwen3-vl-4b-instance"}),
|
||||
],
|
||||
) as mocked_urlopen:
|
||||
manager = LMStudioModelManager(
|
||||
"http://localhost:1234/v1", "test-token", timeout=12
|
||||
)
|
||||
manager.reload_model("qwen/qwen3-vl-4b")
|
||||
|
||||
requests = [call.args[0] for call in mocked_urlopen.call_args_list]
|
||||
assert requests[0].full_url == "http://localhost:1234/api/v1/models"
|
||||
assert requests[0].get_method() == "GET"
|
||||
assert requests[0].get_header("Authorization") == "Bearer test-token"
|
||||
|
||||
assert requests[1].full_url.endswith("/models/unload")
|
||||
assert json.loads(requests[1].data) == {"instance_id": "qwen/qwen3-vl-4b-instance"}
|
||||
|
||||
assert requests[2].full_url.endswith("/models/load")
|
||||
assert json.loads(requests[2].data) == {
|
||||
"model": "qwen/qwen3-vl-4b",
|
||||
"context_length": 16384,
|
||||
"eval_batch_size": 256,
|
||||
"flash_attention": True,
|
||||
"offload_kv_cache_to_gpu": False,
|
||||
}
|
||||
|
||||
|
||||
def test_reload_model_loads_by_key_if_no_instance_is_loaded():
|
||||
model_response = {"models": [{"key": "qwen/model", "loaded_instances": []}]}
|
||||
with patch(
|
||||
"photools3.lm_studio.urlopen",
|
||||
side_effect=[
|
||||
_http_response(model_response),
|
||||
_http_response({"status": "loaded"}),
|
||||
],
|
||||
) as mocked_urlopen:
|
||||
manager = LMStudioModelManager("http://localhost:1234/v1", "token")
|
||||
manager.reload_model("qwen/model")
|
||||
|
||||
assert mocked_urlopen.call_count == 2
|
||||
load_request = mocked_urlopen.call_args.args[0]
|
||||
assert load_request.full_url.endswith("/models/load")
|
||||
assert json.loads(load_request.data) == {"model": "qwen/model"}
|
||||
|
||||
|
||||
def test_reload_model_rejects_ambiguous_model_selection():
|
||||
model_response = {
|
||||
"models": [
|
||||
{
|
||||
"key": "qwen/model",
|
||||
"loaded_instances": [
|
||||
{"id": "instance-1", "config": {}},
|
||||
{"id": "instance-2", "config": {}},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
with patch(
|
||||
"photools3.lm_studio.urlopen", return_value=_http_response(model_response)
|
||||
):
|
||||
manager = LMStudioModelManager("http://localhost:1234/v1", "token")
|
||||
with pytest.raises(LMStudioError, match="Multiple loaded instances"):
|
||||
manager.reload_model("qwen/model")
|
||||
@@ -127,3 +127,39 @@ def test_process_batch_is_sequential_reuses_session_and_continues_after_error():
|
||||
assert all(len(call.args[0]) == 1 for call in session.call.call_args_list)
|
||||
assert writer_class.return_value.write.call_count == 2
|
||||
session.close.assert_called_once()
|
||||
|
||||
|
||||
def test_process_batch_flushes_after_configured_attempt_count(monkeypatch):
|
||||
monkeypatch.setattr("photools3.processing.settings.llm_flush_every_photos", 2)
|
||||
|
||||
class FakePhoto:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
return None
|
||||
|
||||
response = LlmResponseCaptionsAndKeywords(caption="Ein Haus", tags=["architektur"])
|
||||
session = MagicMock()
|
||||
session.call.side_effect = [
|
||||
response,
|
||||
RuntimeError("temporary error"),
|
||||
response,
|
||||
response,
|
||||
response,
|
||||
]
|
||||
paths = [Path(f"{i}.jpg") for i in range(5)]
|
||||
|
||||
with (
|
||||
patch("photools3.processing._create_session", return_value=session),
|
||||
patch("photools3.processing.Photo", FakePhoto),
|
||||
patch("photools3.processing.MetadataWriter"),
|
||||
):
|
||||
outcomes = process_batch(paths, ProcessingMode.BOTH)
|
||||
|
||||
assert len(outcomes) == len(paths)
|
||||
assert session.flush_model.call_count == 2
|
||||
session.close.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user