Skip to content

Commit fcf5a4c

Browse files
committed
Enhance litellm_callbacks.py to skip local ollama/* models and allow gemini-* aliases. Update pyproject.toml to include Playwright as a dependency and add new markers for testing. Modify projects.py to set PROJECTS_ROOT based on the KADY_PROJECTS_ROOT environment variable if defined, improving project path flexibility. Update .coverage file.
1 parent 7fd3057 commit fcf5a4c

22 files changed

Lines changed: 2272 additions & 3 deletions

.coverage

0 Bytes
Binary file not shown.

kady_agent/projects.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@
3030
from typing import Any, Iterable, Optional
3131

3232
REPO_ROOT = Path(__file__).resolve().parents[1]
33-
PROJECTS_ROOT = (REPO_ROOT / "projects").resolve()
33+
PROJECTS_ROOT = (
34+
Path(os.environ["KADY_PROJECTS_ROOT"])
35+
if os.environ.get("KADY_PROJECTS_ROOT")
36+
else REPO_ROOT / "projects"
37+
).resolve()
3438
INDEX_PATH = PROJECTS_ROOT / "index.json"
3539
DEFAULT_PROJECT_ID = "default"
3640

litellm_callbacks.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,14 @@ def _record(self, kwargs: dict[str, Any], response_obj: Any) -> None:
165165
if not isinstance(model, str):
166166
return
167167
# OpenRouter is the only provider the proxy routes to that
168-
# reports a real cost. Skip ollama/* silently.
169-
if not model.startswith("openrouter/") and not model.startswith(
168+
# reports a real cost. The Gemini CLI can reach it through either
169+
# an openrouter/* wildcard or the proxy's gemini-* aliases.
170+
# Skip local ollama/* silently.
171+
if model.startswith("ollama/"):
172+
return
173+
if model.startswith("gemini-"):
174+
pass
175+
elif not model.startswith("openrouter/") and not model.startswith(
170176
"google/"
171177
):
172178
# The proxy resolves ``openrouter/*`` wildcards to the bare

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ dev = [
3535
"anyio>=4.13.0",
3636
"httpx>=0.28.1",
3737
"pyyaml>=6.0.3",
38+
"playwright>=1.59.0",
3839
]
3940

4041
[tool.pytest.ini_options]
@@ -43,6 +44,8 @@ asyncio_mode = "auto"
4344
addopts = "-q"
4445
markers = [
4546
"integration: end-to-end FastAPI / multi-module tests",
47+
"live_e2e: requires live LLM/API services",
48+
"browser: requires Playwright and Chromium",
4649
]
4750
filterwarnings = [
4851
"ignore::DeprecationWarning",

tests/conftest.py

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import os
5+
import shutil
6+
import signal
7+
import socket
8+
import subprocess
9+
import sys
10+
import time
11+
import uuid
12+
from collections.abc import Iterator
13+
from pathlib import Path
14+
from typing import Any
15+
16+
import httpx
17+
import pytest
18+
from fastapi.testclient import TestClient
19+
from dotenv import load_dotenv
20+
21+
REPO_ROOT = Path(__file__).resolve().parents[1]
22+
if str(REPO_ROOT) not in sys.path:
23+
sys.path.insert(0, str(REPO_ROOT))
24+
load_dotenv(REPO_ROOT / "kady_agent" / ".env")
25+
LIVE_BACKEND_URL = os.environ.get("KADY_TEST_BACKEND_URL", "http://127.0.0.1:8000")
26+
LIVE_FRONTEND_URL = os.environ.get("KADY_TEST_FRONTEND_URL", "http://localhost:3000")
27+
LIVE_LITELLM_URL = os.environ.get("KADY_TEST_LITELLM_URL", "http://127.0.0.1:4000")
28+
29+
30+
def pytest_configure(config: pytest.Config) -> None:
31+
config.addinivalue_line("markers", "live_e2e: requires live LLM/API services")
32+
config.addinivalue_line("markers", "browser: requires Playwright and Chromium")
33+
34+
35+
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
36+
# Keep long-lived browser/real-service fixtures from running before the
37+
# async unit/API tests. The verification flow is fast tests first, live E2E
38+
# last, and this also prevents Playwright's loop from confusing pytest-asyncio.
39+
items.sort(key=lambda item: (1 if item.get_closest_marker("live_e2e") else 0, item.nodeid))
40+
41+
42+
@pytest.fixture()
43+
def isolated_projects_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
44+
root = tmp_path / "projects"
45+
root.mkdir()
46+
monkeypatch.setenv("KADY_PROJECTS_ROOT", str(root))
47+
48+
from kady_agent import projects
49+
50+
monkeypatch.setattr(projects, "PROJECTS_ROOT", root)
51+
monkeypatch.setattr(projects, "INDEX_PATH", root / "index.json")
52+
token = projects.set_active_project(projects.DEFAULT_PROJECT_ID)
53+
try:
54+
yield root
55+
finally:
56+
projects.ACTIVE_PROJECT.reset(token)
57+
58+
59+
@pytest.fixture()
60+
def isolated_project(isolated_projects_root: Path) -> str:
61+
from kady_agent import projects
62+
63+
project_id = f"pytest-{uuid.uuid4().hex[:10]}"
64+
projects.create_project("Pytest Project", project_id=project_id)
65+
projects.ensure_project_exists(project_id)
66+
return project_id
67+
68+
69+
@pytest.fixture()
70+
def active_project(isolated_project: str) -> Iterator[str]:
71+
from kady_agent import projects
72+
73+
token = projects.set_active_project(isolated_project)
74+
try:
75+
yield isolated_project
76+
finally:
77+
projects.ACTIVE_PROJECT.reset(token)
78+
79+
80+
@pytest.fixture()
81+
def client(isolated_projects_root: Path) -> Iterator[TestClient]:
82+
import server
83+
84+
# The singleton session service can retain sqlite handles across tests.
85+
server._session_service._services.clear()
86+
with TestClient(server.app) as test_client:
87+
yield test_client
88+
89+
90+
def make_project_headers(project_id: str) -> dict[str, str]:
91+
return {"X-Project-Id": project_id}
92+
93+
94+
@pytest.fixture()
95+
def project_headers(isolated_project: str) -> dict[str, str]:
96+
return make_project_headers(isolated_project)
97+
98+
99+
def _port_open(url: str) -> bool:
100+
parsed = httpx.URL(url)
101+
host = parsed.host or "127.0.0.1"
102+
port = parsed.port or (443 if parsed.scheme == "https" else 80)
103+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
104+
sock.settimeout(0.3)
105+
return sock.connect_ex((host, port)) == 0
106+
107+
108+
def _wait_http(url: str, *, timeout: float = 90.0) -> None:
109+
deadline = time.time() + timeout
110+
last_error: Exception | None = None
111+
while time.time() < deadline:
112+
try:
113+
response = httpx.get(url, timeout=2.0)
114+
if response.status_code < 500:
115+
return
116+
except Exception as exc: # noqa: BLE001
117+
last_error = exc
118+
time.sleep(1.0)
119+
raise AssertionError(f"Timed out waiting for {url}: {last_error}")
120+
121+
122+
def _require_command(name: str) -> None:
123+
if shutil.which(name) is None:
124+
raise AssertionError(f"Required command not found on PATH: {name}")
125+
126+
127+
def require_live_environment() -> None:
128+
for command in ("uv", "node", "npm", "gemini"):
129+
_require_command(command)
130+
if not (os.environ.get("OPENROUTER_API_KEY") or os.environ.get("OR_API_KEY")):
131+
raise AssertionError("OPENROUTER_API_KEY or OR_API_KEY is required for live E2E tests")
132+
133+
134+
@pytest.fixture(scope="session")
135+
def live_projects_root(tmp_path_factory: pytest.TempPathFactory) -> Path:
136+
root = Path(os.environ.get("KADY_TEST_PROJECTS_ROOT", ""))
137+
if not root:
138+
root = tmp_path_factory.mktemp("kady-live-projects")
139+
root.mkdir(parents=True, exist_ok=True)
140+
return root
141+
142+
143+
@pytest.fixture(scope="session")
144+
def live_stack(live_projects_root: Path) -> Iterator[dict[str, str]]:
145+
require_live_environment()
146+
env = os.environ.copy()
147+
env["KADY_PROJECTS_ROOT"] = str(live_projects_root)
148+
env.setdefault("NEXT_PUBLIC_ADK_API_URL", LIVE_BACKEND_URL)
149+
150+
processes: list[subprocess.Popen[str]] = []
151+
152+
try:
153+
if not _port_open(LIVE_LITELLM_URL):
154+
processes.append(
155+
subprocess.Popen(
156+
["uv", "run", "litellm", "--config", "litellm_config.yaml", "--port", "4000"],
157+
cwd=REPO_ROOT,
158+
env=env,
159+
text=True,
160+
stdout=subprocess.PIPE,
161+
stderr=subprocess.STDOUT,
162+
)
163+
)
164+
165+
backend_started = False
166+
if not _port_open(LIVE_BACKEND_URL):
167+
backend_started = True
168+
processes.append(
169+
subprocess.Popen(
170+
["uv", "run", "uvicorn", "server:app", "--port", "8000"],
171+
cwd=REPO_ROOT,
172+
env=env,
173+
text=True,
174+
stdout=subprocess.PIPE,
175+
stderr=subprocess.STDOUT,
176+
)
177+
)
178+
179+
if not _port_open(LIVE_FRONTEND_URL):
180+
processes.append(
181+
subprocess.Popen(
182+
["npm", "run", "dev", "--", "--hostname", "127.0.0.1"],
183+
cwd=REPO_ROOT / "web",
184+
env=env,
185+
text=True,
186+
stdout=subprocess.PIPE,
187+
stderr=subprocess.STDOUT,
188+
)
189+
)
190+
191+
_wait_http(f"{LIVE_BACKEND_URL}/health")
192+
_wait_http(LIVE_FRONTEND_URL)
193+
yield {
194+
"backend": LIVE_BACKEND_URL,
195+
"frontend": LIVE_FRONTEND_URL,
196+
"litellm": LIVE_LITELLM_URL,
197+
"projects_root": str(live_projects_root if backend_started else REPO_ROOT / "projects"),
198+
}
199+
finally:
200+
for proc in reversed(processes):
201+
if proc.poll() is None:
202+
proc.send_signal(signal.SIGTERM)
203+
try:
204+
proc.wait(timeout=10)
205+
except subprocess.TimeoutExpired:
206+
proc.kill()
207+
208+
209+
@pytest.fixture()
210+
def live_project(live_stack: dict[str, str]) -> Iterator[dict[str, Any]]:
211+
project_id = f"live-pytest-{uuid.uuid4().hex[:8]}"
212+
payload = {"id": project_id, "name": "Live Pytest Project", "spendLimitUsd": None}
213+
response = httpx.post(
214+
f"{live_stack['backend']}/projects",
215+
json=payload,
216+
timeout=120.0,
217+
)
218+
response.raise_for_status()
219+
try:
220+
yield {"id": project_id, "headers": make_project_headers(project_id), **live_stack}
221+
finally:
222+
httpx.delete(
223+
f"{live_stack['backend']}/projects/{project_id}",
224+
headers=make_project_headers(project_id),
225+
timeout=30.0,
226+
)
227+
228+
229+
@pytest.fixture(scope="session")
230+
def browser_page(live_stack: dict[str, str]) -> Iterator[Any]:
231+
try:
232+
from playwright.sync_api import sync_playwright
233+
except ImportError as exc: # pragma: no cover - dependency validation
234+
raise AssertionError("Playwright is required for browser tests") from exc
235+
236+
with sync_playwright() as playwright:
237+
browser = playwright.chromium.launch(headless=True)
238+
page = browser.new_page(base_url=live_stack["frontend"])
239+
try:
240+
yield page
241+
finally:
242+
browser.close()
243+
244+
245+
def read_jsonl(path: Path) -> list[dict[str, Any]]:
246+
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]

tests/e2e/test_browser_ui.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from __future__ import annotations
2+
3+
import re
4+
import json
5+
6+
import httpx
7+
import pytest
8+
9+
10+
pytestmark = [pytest.mark.integration, pytest.mark.live_e2e, pytest.mark.browser]
11+
12+
13+
def test_browser_ui_project_sandbox_settings_and_chat(browser_page, live_project) -> None:
14+
page = browser_page
15+
backend = live_project["backend"]
16+
project_id = live_project["id"]
17+
headers = live_project["headers"]
18+
19+
httpx.put(
20+
f"{backend}/sandbox/file?path=browser-e2e.txt",
21+
headers=headers,
22+
content=b"browser fixture",
23+
timeout=30.0,
24+
).raise_for_status()
25+
26+
page.goto("/", wait_until="networkidle", timeout=120_000)
27+
encoded_project_id = json.dumps(project_id)
28+
page.evaluate(
29+
f"""
30+
const projectId = {encoded_project_id};
31+
window.localStorage.setItem('kady:activeProjectId', projectId);
32+
document.cookie = `kady-project=${{encodeURIComponent(projectId)}}; path=/`;
33+
window.dispatchEvent(new CustomEvent('kady:project-changed', {{ detail: {{ id: projectId }} }}));
34+
"""
35+
)
36+
page.reload(wait_until="networkidle", timeout=120_000)
37+
38+
page.get_by_label("Switch project").wait_for(timeout=30_000)
39+
page.get_by_placeholder(re.compile("Ask Kady anything")).wait_for(timeout=30_000)
40+
page.get_by_text("browser-e2e.txt").wait_for(timeout=30_000)
41+
42+
page.get_by_text("browser-e2e.txt").click()
43+
page.get_by_text("browser fixture").wait_for(timeout=30_000)
44+
45+
page.get_by_label("Open settings").click()
46+
page.get_by_text("Settings").wait_for(timeout=10_000)
47+
page.get_by_role("tab", name="MCP Servers").wait_for(timeout=10_000)
48+
page.get_by_role("tab", name="Browser").click()
49+
page.get_by_role("heading", name="Browser automation").wait_for(timeout=10_000)
50+
page.keyboard.press("Escape")
51+
52+
prompt = (
53+
"Browser E2E smoke test: reply with the exact lowercase phrase formed "
54+
"by joining browser, e2e, and ok with hyphens. Do not call tools."
55+
)
56+
input_box = page.get_by_placeholder(re.compile("Ask Kady anything"))
57+
input_box.fill(prompt)
58+
input_box.press("Enter")
59+
page.get_by_text("browser-e2e-ok", exact=False).wait_for(timeout=240_000)
60+
61+
cost_response = httpx.get(
62+
f"{backend}/projects/{project_id}/costs", headers=headers, timeout=30.0
63+
)
64+
cost_response.raise_for_status()
65+
assert "budget" in cost_response.json()

0 commit comments

Comments
 (0)