feat: add ProfileManager service with JSON persistence
This commit is contained in:
@@ -26,3 +26,75 @@ def test_profile_apply_request_fields():
|
|||||||
def test_profile_apply_response():
|
def test_profile_apply_response():
|
||||||
resp = ProfileApplyResponse(scenario_path="scenarios/offline/sample.yaml", patched_fields=["judge_model"])
|
resp = ProfileApplyResponse(scenario_path="scenarios/offline/sample.yaml", patched_fields=["judge_model"])
|
||||||
assert "judge_model" in resp.patched_fields
|
assert "judge_model" in resp.patched_fields
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ProfileManager service tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
import json
|
||||||
|
from webapp.services.profile_manager import ProfileManager
|
||||||
|
|
||||||
|
|
||||||
|
def _make_manager(tmp_path):
|
||||||
|
store = tmp_path / "profiles.json"
|
||||||
|
return ProfileManager(store_path=store)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_profile(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
p = mgr.create(name="Local", model="deepseek-v4-flash",
|
||||||
|
base_url="http://localhost/v1", api_key="sk-x")
|
||||||
|
assert p.profile_id != ""
|
||||||
|
assert p.name == "Local"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_profiles(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
mgr.create(name="A", model="m1", base_url="http://a/v1", api_key="k1")
|
||||||
|
mgr.create(name="B", model="m2", base_url="http://b/v1", api_key="k2")
|
||||||
|
profiles = mgr.list_all()
|
||||||
|
assert len(profiles) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_profile(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
created = mgr.create(name="X", model="m", base_url="http://x/v1", api_key="k")
|
||||||
|
fetched = mgr.get(created.profile_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.name == "X"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_profile(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
p = mgr.create(name="Old", model="m", base_url="http://x/v1", api_key="k")
|
||||||
|
updated = mgr.update(p.profile_id, name="New", model="m2",
|
||||||
|
base_url="http://x/v1", api_key="k", timeout_seconds=60)
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.name == "New"
|
||||||
|
assert updated.model == "m2"
|
||||||
|
assert updated.timeout_seconds == 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_profile(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
p = mgr.create(name="Del", model="m", base_url="http://x/v1", api_key="k")
|
||||||
|
assert mgr.delete(p.profile_id) is True
|
||||||
|
assert mgr.get(p.profile_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_persistence(tmp_path):
|
||||||
|
store = tmp_path / "profiles.json"
|
||||||
|
mgr1 = ProfileManager(store_path=store)
|
||||||
|
p = mgr1.create(name="Persist", model="m", base_url="http://x/v1", api_key="k")
|
||||||
|
mgr2 = ProfileManager(store_path=store)
|
||||||
|
assert mgr2.get(p.profile_id) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_nonexistent(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
assert mgr.get("does-not-exist") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_nonexistent(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
assert mgr.delete("does-not-exist") is False
|
||||||
|
|||||||
137
webapp/services/profile_manager.py
Normal file
137
webapp/services/profile_manager.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""In-memory + JSON-file LLM profile manager.
|
||||||
|
|
||||||
|
Profiles are kept in a dict keyed by profile_id and written to a JSON file
|
||||||
|
on every mutation, so they survive server restarts. The pattern mirrors
|
||||||
|
TaskManager but without threading concerns beyond a simple lock (profiles
|
||||||
|
are only mutated by API calls in FastAPI request handlers).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from webapp.models import LLMProfile
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_STORE = Path(__file__).resolve().parents[2] / "configs" / "llm_profiles.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileManager:
|
||||||
|
"""Manages LLM profiles with in-memory cache and JSON file persistence."""
|
||||||
|
|
||||||
|
def __init__(self, store_path: Path = _DEFAULT_STORE) -> None:
|
||||||
|
self._store_path = store_path
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._profiles: dict[str, LLMProfile] = {}
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def list_all(self) -> list[LLMProfile]:
|
||||||
|
"""Return all profiles sorted by creation time."""
|
||||||
|
with self._lock:
|
||||||
|
return sorted(self._profiles.values(), key=lambda p: p.created_at)
|
||||||
|
|
||||||
|
def get(self, profile_id: str) -> LLMProfile | None:
|
||||||
|
"""Return one profile by id, or None if not found."""
|
||||||
|
with self._lock:
|
||||||
|
return self._profiles.get(profile_id)
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
model: str,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str,
|
||||||
|
timeout_seconds: int = 30,
|
||||||
|
) -> LLMProfile:
|
||||||
|
"""Create and persist a new profile, returning it."""
|
||||||
|
now = _now_iso()
|
||||||
|
profile = LLMProfile(
|
||||||
|
profile_id=uuid.uuid4().hex[:12],
|
||||||
|
name=name,
|
||||||
|
model=model,
|
||||||
|
base_url=base_url,
|
||||||
|
api_key=api_key,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
self._profiles[profile.profile_id] = profile
|
||||||
|
self._persist()
|
||||||
|
return profile
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
profile_id: str,
|
||||||
|
name: str,
|
||||||
|
model: str,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str,
|
||||||
|
timeout_seconds: int = 30,
|
||||||
|
) -> LLMProfile | None:
|
||||||
|
"""Update an existing profile in-place; returns None if not found."""
|
||||||
|
with self._lock:
|
||||||
|
existing = self._profiles.get(profile_id)
|
||||||
|
if existing is None:
|
||||||
|
return None
|
||||||
|
updated = existing.model_copy(update={
|
||||||
|
"name": name,
|
||||||
|
"model": model,
|
||||||
|
"base_url": base_url,
|
||||||
|
"api_key": api_key,
|
||||||
|
"timeout_seconds": timeout_seconds,
|
||||||
|
"updated_at": _now_iso(),
|
||||||
|
})
|
||||||
|
self._profiles[profile_id] = updated
|
||||||
|
self._persist()
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def delete(self, profile_id: str) -> bool:
|
||||||
|
"""Remove a profile; returns True if deleted, False if not found."""
|
||||||
|
with self._lock:
|
||||||
|
if profile_id not in self._profiles:
|
||||||
|
return False
|
||||||
|
del self._profiles[profile_id]
|
||||||
|
self._persist()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Persistence helpers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
"""Load profiles from the JSON store file, ignoring missing/corrupt files."""
|
||||||
|
if not self._store_path.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = json.loads(self._store_path.read_text(encoding="utf-8"))
|
||||||
|
for raw in data.get("profiles", []):
|
||||||
|
p = LLMProfile.model_validate(raw)
|
||||||
|
self._profiles[p.profile_id] = p
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass # Corrupt store — start fresh
|
||||||
|
|
||||||
|
def _persist(self) -> None:
|
||||||
|
"""Write current profiles to the JSON store file (called under lock)."""
|
||||||
|
self._store_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = {"profiles": [p.model_dump() for p in self._profiles.values()]}
|
||||||
|
self._store_path.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton shared by FastAPI routes.
|
||||||
|
profile_manager = ProfileManager()
|
||||||
Reference in New Issue
Block a user