Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/server/desktop_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,60 @@ async def model_info(request: Request) -> Response:
except Exception: # noqa: BLE001 — inspection endpoint, degrade soft
return JSONResponse({"provider": None, "model": None})

async def model_options_rest(request: Request) -> Response:
if not _token_ok(state, _rest_token(request)):
return JSONResponse({"error": "unauthorized"}, status_code=401)
from src.server.desktop_gateway_methods import _catalog_from_config

import asyncio as _asyncio
return JSONResponse(await _asyncio.to_thread(_catalog_from_config))

async def model_auxiliary(request: Request) -> Response:
if not _token_ok(state, _rest_token(request)):
return JSONResponse({"error": "unauthorized"}, status_code=401)
# Auxiliary task-model assignments aren't wired to a control yet; the
# panel reads `main` and an (empty) task list and renders "using the
# main model for everything", which is the true state.
from src.config import get_default_provider, get_provider_config

try:
provider = get_default_provider()
model = (get_provider_config(provider) or {}).get("default_model")
except Exception: # noqa: BLE001
provider, model = None, None
return JSONResponse({"main": {"model": model, "provider": provider}, "tasks": []})

async def config_schema(request: Request) -> Response:
if not _token_ok(state, _rest_token(request)):
return JSONResponse({"error": "unauthorized"}, status_code=401)
# No dynamic config schema in this backend — the structured settings
# panels render from their own known fields; the schema only drives the
# Advanced tab's generated rows, which degrade to none.
return JSONResponse({"fields": {}, "category_order": []})

async def cron_jobs(request: Request) -> Response:
if not _token_ok(state, _rest_token(request)):
return JSONResponse({"error": "unauthorized"}, status_code=401)
return JSONResponse({"jobs": []})

async def elevenlabs_voices(request: Request) -> Response:
if not _token_ok(state, _rest_token(request)):
return JSONResponse({"error": "unauthorized"}, status_code=401)
return JSONResponse({"voices": []})

async def session_detail(request: Request) -> Response:
if not _token_ok(state, _rest_token(request)):
return JSONResponse({"error": "unauthorized"}, status_code=401)
from src.server.desktop_sessions import load_session_messages

sid = request.path_params["session_id"]
found = load_session_messages(state.saved_sessions_dir(), sid)
if found is None:
return JSONResponse({"error": "session not found"}, status_code=404)
return JSONResponse({"session_id": found["session_id"],
"messages": found["messages"],
"message_count": found["message_count"]})

def _sessions_slice(request: Request, *, profile_tag: str = "default") -> dict[str, Any]:
"""One filtered slice of the saved-session list (shared by the
profile-scoped route and the batched sidebar)."""
Expand Down Expand Up @@ -334,6 +388,12 @@ async def not_found(request: Request) -> Response:
Route("/api/profiles/sessions", profile_sessions),
Route("/api/profiles/sessions/sidebar", sidebar_sessions),
Route("/api/model/info", model_info),
Route("/api/model/options", model_options_rest),
Route("/api/model/auxiliary", model_auxiliary),
Route("/api/config/schema", config_schema),
Route("/api/cron/jobs", cron_jobs),
Route("/api/audio/elevenlabs/voices", elevenlabs_voices),
Route("/api/sessions/{session_id}", session_detail),
Route("/api/audio/transcribe", audio_transcribe, methods=["POST"]),
Route("/", index),
WebSocketRoute("/api/ws", gateway_ws),
Expand Down
39 changes: 39 additions & 0 deletions tests/server/test_desktop_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,45 @@ def test_model_info_reports_defaults(
assert body == {"provider": "anthropic", "model": "claude-fable-5"}


def test_settings_panel_rest_routes(
rest: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The Model/Voice/Advanced settings panels each await a REST route; a 404
on any leaves the panel stuck on skeletons. Assert the shapes they read."""
monkeypatch.setattr("src.config.get_default_provider", lambda: "anthropic")
monkeypatch.setattr("src.config.get_provider_config",
lambda n: {"default_model": "claude-sonnet-4-6"})

options = rest.get("/api/model/options", headers=AUTH).json()
assert "providers" in options and len(options["providers"]) > 5

aux = rest.get("/api/model/auxiliary", headers=AUTH).json()
assert aux["main"] == {"model": "claude-sonnet-4-6", "provider": "anthropic"}
assert aux["tasks"] == []

schema = rest.get("/api/config/schema", headers=AUTH).json()
assert schema == {"fields": {}, "category_order": []}

assert rest.get("/api/cron/jobs", headers=AUTH).json() == {"jobs": []}
assert rest.get("/api/audio/elevenlabs/voices", headers=AUTH).json() == {"voices": []}

# All are token-gated.
for path in ("/api/model/options", "/api/model/auxiliary", "/api/config/schema",
"/api/cron/jobs", "/api/audio/elevenlabs/voices"):
assert rest.get(path).status_code == 401


def test_session_detail_route(rest: TestClient, tmp_path: Path) -> None:
_write_session(
tmp_path / "sessions", "chat-x", preview="hi", count=1,
messages=[{"role": "user", "content": "hi"}],
)
body = rest.get("/api/sessions/chat-x", headers=AUTH).json()
assert body["session_id"] == "chat-x"
assert body["message_count"] == 1
assert rest.get("/api/sessions/missing", headers=AUTH).status_code == 404


def test_profile_sessions_slice_filters_sources(
rest: TestClient, tmp_path: Path
) -> None:
Expand Down
Loading