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():
|
||||
resp = ProfileApplyResponse(scenario_path="scenarios/offline/sample.yaml", patched_fields=["judge_model"])
|
||||
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