From f8d5b7857a07e93824ccdee62d9f9fb7c47175ce Mon Sep 17 00:00:00 2001 From: Alvaro Parker Date: Tue, 6 Oct 2026 18:23:08 -0300 Subject: [PATCH] refactor(api): scope uv to s2s-client and validate compatible responses --- .../.pre-commit-config.yaml | 10 +- .python-version => s2s-client/.python-version | 0 Makefile => s2s-client/Makefile | 8 +- pyproject.toml => s2s-client/pyproject.toml | 9 +- .../s2s_client}/__init__.py | 0 .../s2s_client}/api/__init__.py | 0 s2s-client/s2s_client/api/responses.py | 18 +++ .../s2s_client}/api/router.py | 3 +- .../s2s_client}/api/routes/__init__.py | 0 .../s2s_client}/api/routes/apipilot.py | 40 +++--- .../s2s_client}/api/routes/caller.py | 20 ++- .../s2s_client}/api/routes/config.py | 11 +- .../s2s_client}/api/routes/search.py | 6 +- .../s2s_client}/api/routes/sessions.py | 31 +++-- .../s2s_client}/core/__init__.py | 0 .../s2s_client}/core/config.py | 2 +- .../s2s_client}/core/diagnostics.py | 0 .../s2s_client}/core/logging.py | 0 .../s2s_client}/core/urls.py | 0 {s2s_client => s2s-client/s2s_client}/main.py | 0 .../s2s_client}/models/__init__.py | 0 .../s2s_client}/models/requests.py | 15 ++- s2s-client/s2s_client/models/responses.py | 125 ++++++++++++++++++ .../s2s_client}/providers/__init__.py | 0 .../s2s_client}/providers/apipilot.py | 0 .../s2s_client}/providers/caller.py | 0 .../s2s_client}/realtime/__init__.py | 0 .../s2s_client}/realtime/events.py | 0 .../s2s_client}/realtime/proxy.py | 0 .../s2s_client}/services/__init__.py | 0 .../s2s_client}/services/context.py | 0 .../s2s_client}/services/reasoning.py | 10 +- .../s2s_client}/services/tool_context.py | 0 s2s-client/server.py | 2 +- .../tests}/fixtures/legacy-contracts.json | 0 .../tests}/test_compatibility.py | 0 s2s-client/tests/test_models.py | 71 ++++++++++ {tests => s2s-client/tests}/test_server.py | 2 +- uv.lock => s2s-client/uv.lock | 0 39 files changed, 316 insertions(+), 67 deletions(-) rename .pre-commit-config.yaml => s2s-client/.pre-commit-config.yaml (60%) rename .python-version => s2s-client/.python-version (100%) rename Makefile => s2s-client/Makefile (83%) rename pyproject.toml => s2s-client/pyproject.toml (85%) rename {s2s_client => s2s-client/s2s_client}/__init__.py (100%) rename {s2s_client => s2s-client/s2s_client}/api/__init__.py (100%) create mode 100644 s2s-client/s2s_client/api/responses.py rename {s2s_client => s2s-client/s2s_client}/api/router.py (86%) rename {s2s_client => s2s-client/s2s_client}/api/routes/__init__.py (100%) rename {s2s_client => s2s-client/s2s_client}/api/routes/apipilot.py (83%) rename {s2s_client => s2s-client/s2s_client}/api/routes/caller.py (95%) rename {s2s_client => s2s-client/s2s_client}/api/routes/config.py (86%) rename {s2s_client => s2s-client/s2s_client}/api/routes/search.py (92%) rename {s2s_client => s2s-client/s2s_client}/api/routes/sessions.py (84%) rename {s2s_client => s2s-client/s2s_client}/core/__init__.py (100%) rename {s2s_client => s2s-client/s2s_client}/core/config.py (99%) rename {s2s_client => s2s-client/s2s_client}/core/diagnostics.py (100%) rename {s2s_client => s2s-client/s2s_client}/core/logging.py (100%) rename {s2s_client => s2s-client/s2s_client}/core/urls.py (100%) rename {s2s_client => s2s-client/s2s_client}/main.py (100%) rename {s2s_client => s2s-client/s2s_client}/models/__init__.py (100%) rename {s2s_client => s2s-client/s2s_client}/models/requests.py (74%) create mode 100644 s2s-client/s2s_client/models/responses.py rename {s2s_client => s2s-client/s2s_client}/providers/__init__.py (100%) rename {s2s_client => s2s-client/s2s_client}/providers/apipilot.py (100%) rename {s2s_client => s2s-client/s2s_client}/providers/caller.py (100%) rename {s2s_client => s2s-client/s2s_client}/realtime/__init__.py (100%) rename {s2s_client => s2s-client/s2s_client}/realtime/events.py (100%) rename {s2s_client => s2s-client/s2s_client}/realtime/proxy.py (100%) rename {s2s_client => s2s-client/s2s_client}/services/__init__.py (100%) rename {s2s_client => s2s-client/s2s_client}/services/context.py (100%) rename {s2s_client => s2s-client/s2s_client}/services/reasoning.py (99%) rename {s2s_client => s2s-client/s2s_client}/services/tool_context.py (100%) rename {tests => s2s-client/tests}/fixtures/legacy-contracts.json (100%) rename {tests => s2s-client/tests}/test_compatibility.py (100%) create mode 100644 s2s-client/tests/test_models.py rename {tests => s2s-client/tests}/test_server.py (99%) rename uv.lock => s2s-client/uv.lock (100%) diff --git a/.pre-commit-config.yaml b/s2s-client/.pre-commit-config.yaml similarity index 60% rename from .pre-commit-config.yaml rename to s2s-client/.pre-commit-config.yaml index 1a2c428..59ed36d 100644 --- a/.pre-commit-config.yaml +++ b/s2s-client/.pre-commit-config.yaml @@ -7,19 +7,21 @@ repos: hooks: - id: ruff-check name: Ruff lint - entry: uv run --locked ruff check --force-exclude + entry: uv run --project s2s-client --locked ruff check --force-exclude language: unsupported types_or: [python, pyi] + files: ^s2s-client/ require_serial: true - id: ruff-format name: Ruff formatting - entry: uv run --locked ruff format --check --force-exclude + entry: uv run --project s2s-client --locked ruff format --check --force-exclude language: unsupported types_or: [python, pyi] + files: ^s2s-client/ require_serial: true - id: mypy name: Python types - entry: uv run --locked mypy + entry: uv run --directory s2s-client --locked mypy language: unsupported pass_filenames: false - files: ^(s2s_client/|s2s-client/server\.py|pyproject\.toml|uv\.lock) + files: ^s2s-client/(s2s_client/|server\.py|pyproject\.toml|uv\.lock) diff --git a/.python-version b/s2s-client/.python-version similarity index 100% rename from .python-version rename to s2s-client/.python-version diff --git a/Makefile b/s2s-client/Makefile similarity index 83% rename from Makefile rename to s2s-client/Makefile index 33dce32..87a0d0c 100644 --- a/Makefile +++ b/s2s-client/Makefile @@ -45,16 +45,16 @@ test: check: lock-check format-check lint type-check test dev: - ./start_ui.sh + ../start_ui.sh hooks-install: - $(UV_RUN) pre-commit install --install-hooks + $(UV_RUN) pre-commit install --install-hooks --config s2s-client/.pre-commit-config.yaml hooks-run: - $(UV_RUN) pre-commit run --all-files + $(UV_RUN) pre-commit run --all-files --config s2s-client/.pre-commit-config.yaml test-launcher: - npm --prefix s2s-frontend run test:launcher + npm --prefix ../s2s-frontend run test:launcher test-image: bash scripts/test_docker_image.sh diff --git a/pyproject.toml b/s2s-client/pyproject.toml similarity index 85% rename from pyproject.toml rename to s2s-client/pyproject.toml index cd8f58f..f221e33 100644 --- a/pyproject.toml +++ b/s2s-client/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "aquaportal-s2s-client" version = "0.1.0" description = "FastAPI integration and realtime proxy for the Vocero frontend" -readme = "s2s-client/README.md" +readme = "README.md" requires-python = ">=3.12,<3.13" dependencies = [ "fastapi==0.115.6", @@ -34,7 +34,7 @@ include = ["s2s_client*"] [tool.ruff] target-version = "py312" line-length = 120 -include = ["pyproject.toml", "s2s_client/**/*.py", "s2s-client/server.py", "tests/**/*.py", "scripts/**/*.py"] +include = ["pyproject.toml", "s2s_client/**/*.py", "server.py", "tests/**/*.py", "scripts/**/*.py"] [tool.ruff.lint] select = ["E4", "E7", "E9", "F", "I", "B", "UP"] @@ -48,7 +48,7 @@ line-ending = "auto" [tool.mypy] python_version = "3.12" plugins = ["pydantic.mypy"] -files = ["s2s_client", "s2s-client/server.py"] +files = ["s2s_client", "server.py"] check_untyped_defs = true disallow_untyped_defs = true no_implicit_optional = true @@ -66,5 +66,4 @@ warn_untyped_fields = true [tool.pytest.ini_options] testpaths = ["tests"] -pythonpath = [".", "s2s-client"] - +pythonpath = ["."] diff --git a/s2s_client/__init__.py b/s2s-client/s2s_client/__init__.py similarity index 100% rename from s2s_client/__init__.py rename to s2s-client/s2s_client/__init__.py diff --git a/s2s_client/api/__init__.py b/s2s-client/s2s_client/api/__init__.py similarity index 100% rename from s2s_client/api/__init__.py rename to s2s-client/s2s_client/api/__init__.py diff --git a/s2s-client/s2s_client/api/responses.py b/s2s-client/s2s_client/api/responses.py new file mode 100644 index 0000000..933133d --- /dev/null +++ b/s2s-client/s2s_client/api/responses.py @@ -0,0 +1,18 @@ +"""Validate service-owned response structure without rewriting the wire body.""" + +from typing import Any + +from fastapi.responses import JSONResponse +from pydantic import BaseModel + + +def validated_json_response( + content: Any, + model: type[BaseModel], + *, + status_code: int = 200, +) -> JSONResponse: + model.model_validate(content) + # model_dump would insert defaults, potentially coerce values, and reorder + # fields. Keeping the original JSONResponse serialization is intentional. + return JSONResponse(content=content, status_code=status_code) diff --git a/s2s_client/api/router.py b/s2s-client/s2s_client/api/router.py similarity index 86% rename from s2s_client/api/router.py rename to s2s-client/s2s_client/api/router.py index 66676e9..20d4881 100644 --- a/s2s_client/api/router.py +++ b/s2s-client/s2s_client/api/router.py @@ -3,13 +3,14 @@ from fastapi import APIRouter from s2s_client.api.routes import apipilot, caller, config, search, sessions +from s2s_client.models.responses import ReasoningResponse from s2s_client.realtime.proxy import realtime_proxy from s2s_client.services.reasoning import reasoning router = APIRouter() router.include_router(config.router) router.include_router(apipilot.router) -router.add_api_route("/api/reasoning", reasoning, methods=["POST"]) +router.add_api_route("/api/reasoning", reasoning, methods=["POST"], response_model=ReasoningResponse) router.add_api_websocket_route("/v1/realtime", realtime_proxy) router.include_router(caller.router) router.include_router(search.router) diff --git a/s2s_client/api/routes/__init__.py b/s2s-client/s2s_client/api/routes/__init__.py similarity index 100% rename from s2s_client/api/routes/__init__.py rename to s2s-client/s2s_client/api/routes/__init__.py diff --git a/s2s_client/api/routes/apipilot.py b/s2s-client/s2s_client/api/routes/apipilot.py similarity index 83% rename from s2s_client/api/routes/apipilot.py rename to s2s-client/s2s_client/api/routes/apipilot.py index b195ef4..e704da1 100644 --- a/s2s_client/api/routes/apipilot.py +++ b/s2s-client/s2s_client/api/routes/apipilot.py @@ -1,24 +1,24 @@ from __future__ import annotations -from typing import Any - from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse +from s2s_client.api.responses import validated_json_response from s2s_client.core import config as settings from s2s_client.core import logging as app_logging from s2s_client.models import requests as models_requests +from s2s_client.models.responses import ToolCatalogResponse, ToolExecutionResponse from s2s_client.providers import apipilot as providers_apipilot router = APIRouter() -@router.get("/api/apipilot/tools", response_model=None) +@router.get("/api/apipilot/tools", response_model=ToolCatalogResponse) async def apipilot_list_tools( request: Request, include_unauthorized: bool = True, refresh: bool = False, -) -> dict[str, Any] | JSONResponse: +) -> JSONResponse: """Public proxy for ApiPilot tool discovery (guide §2.1). Forwards Authorization / x-demo-session headers from the caller to the @@ -31,7 +31,8 @@ async def apipilot_list_tools( S2S_APIPILOT_TTL_SEC). The client may force a refresh via `?refresh=true` (e.g. after a user just logged in and previously saw only requires_auth tools).""" if not settings.S2S_APIPILOT_TOOLS_URL: - return JSONResponse( + return validated_json_response( + model=ToolCatalogResponse, status_code=501, content={ "success": False, @@ -64,7 +65,8 @@ async def apipilot_list_tools( "raw": None, } ) - return JSONResponse( + return validated_json_response( + model=ToolCatalogResponse, status_code=exc.status_code, content={"success": False, "data": None, "error": detail}, ) @@ -74,18 +76,21 @@ async def apipilot_list_tools( bool(include_unauthorized), bool(refresh), ) - return { - "success": True, - "data": { - "count": len(tools), - "include_unauthorized": bool(include_unauthorized), - "tools": tools, + return validated_json_response( + { + "success": True, + "data": { + "count": len(tools), + "include_unauthorized": bool(include_unauthorized), + "tools": tools, + }, + "error": None, }, - "error": None, - } + ToolCatalogResponse, + ) -@router.post("/api/apipilot/execute") +@router.post("/api/apipilot/execute", response_model=ToolExecutionResponse) async def apipilot_execute(request: Request, body: models_requests.ApipilotExecuteRequest) -> JSONResponse: """Public proxy for ApiPilot /execute (guide §2.2). @@ -97,7 +102,8 @@ async def apipilot_execute(request: Request, body: models_requests.ApipilotExecu Show only `error.message` to the end user. `error.code`, `error.status_code` and `error.raw` are for internal auditing (guide §2.2, recommended UI errors).""" if not settings.S2S_APIPILOT_TOOLS_URL: - return JSONResponse( + return validated_json_response( + model=ToolExecutionResponse, status_code=501, content={ "success": False, @@ -138,4 +144,4 @@ async def apipilot_execute(request: Request, body: models_requests.ApipilotExecu envelope.get("success"), status_code, ) - return JSONResponse(status_code=status_code, content=envelope) + return validated_json_response(envelope, ToolExecutionResponse, status_code=status_code) diff --git a/s2s_client/api/routes/caller.py b/s2s-client/s2s_client/api/routes/caller.py similarity index 95% rename from s2s_client/api/routes/caller.py rename to s2s-client/s2s_client/api/routes/caller.py index 6ff0b61..0f96047 100644 --- a/s2s_client/api/routes/caller.py +++ b/s2s-client/s2s_client/api/routes/caller.py @@ -6,15 +6,17 @@ import httpx from fastapi import APIRouter, HTTPException from fastapi.responses import JSONResponse +from s2s_client.api.responses import validated_json_response from s2s_client.core import config as settings from s2s_client.core import logging as app_logging +from s2s_client.models.responses import ReportsResponse, UserProfileResponse from s2s_client.providers import caller as providers_caller from s2s_client.services import context as services_context router = APIRouter() -@router.get("/api/user/phone") +@router.get("/api/user/phone", response_model=UserProfileResponse) async def user_by_phone(phoneNumber: str) -> JSONResponse: """HTTP passthrough: fetches caller identity / profile from the external info endpoint set via VOCERO_USER_PHONE_AUTH_URL and forwards the raw @@ -77,10 +79,12 @@ async def user_by_phone(phoneNumber: str) -> JSONResponse: status_code=502, detail="El servicio de información del usuario devolvió un JSON no válido.", ) - return JSONResponse(status_code=resp.status_code if resp.status_code != 204 else 200, content=payload) + return validated_json_response( + payload, UserProfileResponse, status_code=resp.status_code if resp.status_code != 204 else 200 + ) -@router.get("/api/user/reports") +@router.get("/api/user/reports", response_model=ReportsResponse) async def user_reports(phoneNumber: str | None = None) -> JSONResponse: """Fetch + normalize the scheduled reports list for the caller UI. @@ -116,7 +120,8 @@ async def user_reports(phoneNumber: str | None = None) -> JSONResponse: """ # ── MASS-INJECTION KILL SWITCH (user request) ───────────────────────── if not settings.S2S_INJECT_REPORTS: - return JSONResponse( + return validated_json_response( + model=ReportsResponse, status_code=200, content={ "count": 0, @@ -145,7 +150,7 @@ async def user_reports(phoneNumber: str | None = None) -> JSONResponse: # guarantees UI and LLM see exactly the same filtered/sorted list. ctx_str = services_context._resolve_reports_context(reports_raw) if not ctx_str or not isinstance(reports_raw, list): - return JSONResponse( + return validated_json_response( { "count": 0, "wrappedLen": 0, @@ -153,7 +158,8 @@ async def user_reports(phoneNumber: str | None = None) -> JSONResponse: "entries": [], "skipped": True, "reason": "Lista vacía, error del servicio de origen o ningún contenido disponible.", - } + }, + ReportsResponse, ) # Rebuild hints + normalized entries with the exact filtered output items_list: list = reports_raw @@ -280,4 +286,4 @@ async def user_reports(phoneNumber: str | None = None) -> JSONResponse: if truncated_ui_note is not None: resp_body["truncated"] = True resp_body["note"] = truncated_ui_note - return JSONResponse(resp_body) + return validated_json_response(resp_body, ReportsResponse) diff --git a/s2s_client/api/routes/config.py b/s2s-client/s2s_client/api/routes/config.py similarity index 86% rename from s2s_client/api/routes/config.py rename to s2s-client/s2s_client/api/routes/config.py index 55f3c8b..576fb56 100644 --- a/s2s_client/api/routes/config.py +++ b/s2s-client/s2s_client/api/routes/config.py @@ -1,12 +1,13 @@ from __future__ import annotations -from typing import Any - from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse +from s2s_client.api.responses import validated_json_response from s2s_client.core import config as settings from s2s_client.core import logging as app_logging from s2s_client.core import urls as core_urls +from s2s_client.models.responses import ConfigResponse router = APIRouter() @@ -15,8 +16,8 @@ def _effective_s2s_url(request: Request) -> str: return settings.SPEECH_TO_SPEECH_URL or core_urls._proxy_public_url(request) -@router.get("/api/config") -async def config(request: Request) -> dict[str, Any]: +@router.get("/api/config", response_model=ConfigResponse) +async def config(request: Request) -> JSONResponse: """Client bootstrap: whether web search is available, whether the deploy runs behind a load balancer (so the browser uses the /api/session proxy), and whether the user may instead set a direct @@ -51,4 +52,4 @@ async def config(request: Request) -> dict[str, Any]: settings.S2S_THEME or "-", out["apipilot"], ) - return out + return validated_json_response(out, ConfigResponse) diff --git a/s2s_client/api/routes/search.py b/s2s-client/s2s_client/api/routes/search.py similarity index 92% rename from s2s_client/api/routes/search.py rename to s2s-client/s2s_client/api/routes/search.py index 3bacc79..bb7ae6e 100644 --- a/s2s_client/api/routes/search.py +++ b/s2s-client/s2s_client/api/routes/search.py @@ -6,14 +6,16 @@ import httpx from fastapi import APIRouter, HTTPException from fastapi.responses import JSONResponse +from s2s_client.api.responses import validated_json_response from s2s_client.core import config as settings from s2s_client.core import logging as app_logging from s2s_client.models import requests as models_requests +from s2s_client.models.responses import SearchResponse router = APIRouter() -@router.post("/api/search") +@router.post("/api/search", response_model=SearchResponse) async def search(req: models_requests.SearchRequest) -> JSONResponse: """Proxy a Google search via Serper.dev. The key stays on the server unless the user brought their own (then theirs is used for this request only).""" @@ -87,4 +89,4 @@ async def search(req: models_requests.SearchRequest) -> JSONResponse: len(results), [r.get("title", "")[:40] for r in results], ) - return JSONResponse({"query": query, "answer": answer, "results": results}) + return validated_json_response({"query": query, "answer": answer, "results": results}, SearchResponse) diff --git a/s2s_client/api/routes/sessions.py b/s2s-client/s2s_client/api/routes/sessions.py similarity index 84% rename from s2s_client/api/routes/sessions.py rename to s2s-client/s2s_client/api/routes/sessions.py index 01fe706..a401359 100644 --- a/s2s_client/api/routes/sessions.py +++ b/s2s-client/s2s_client/api/routes/sessions.py @@ -6,13 +6,16 @@ import httpx from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse +from s2s_client.api.responses import validated_json_response from s2s_client.core import config as settings from s2s_client.core import logging as app_logging +from s2s_client.models.requests import QueueEndPayload +from s2s_client.models.responses import AllocationResponse, QueueCancellationResponse router = APIRouter() -@router.post("/api/session") +@router.post("/api/session", response_model=AllocationResponse) async def session(request: Request) -> JSONResponse: """Relay a session grant or queue ticket, keeping the LB address server-side. @@ -45,7 +48,7 @@ async def session(request: Request) -> JSONResponse: body = _safe_json(lb) if body.get("state") == "at_capacity": app_logging.logger.debug("[EXT][LB] session: at_capacity — queue full") - return JSONResponse({"state": "at_capacity"}, status_code=503) + return validated_json_response({"state": "at_capacity"}, AllocationResponse, status_code=503) if lb.status_code != 200: # The LB's error body may name the reason (e.g. capacity); it carries no @@ -72,10 +75,10 @@ async def session(request: Request) -> JSONResponse: data.get("queue_id") or data.get("queueId"), data.get("position"), ) - return JSONResponse(data) + return validated_json_response(data, AllocationResponse) -@router.get("/api/queue/{queue_id}") +@router.get("/api/queue/{queue_id}", response_model=AllocationResponse) async def queue_status(queue_id: str, request: Request) -> JSONResponse: """Relay a waiting ticket's position or its newly claimed session grant.""" client_ip = request.client.host if request.client else "?" @@ -106,7 +109,7 @@ async def queue_status(queue_id: str, request: Request) -> JSONResponse: # Ticket unknown/expired (reaped after we stopped polling). Tell the client # to start over rather than spin. app_logging.logger.debug("[EXT][LB] queue: queueId=%s EXPIRED (404)", queue_id[:16]) - return JSONResponse({"state": "expired"}, status_code=404) + return validated_json_response({"state": "expired"}, AllocationResponse, status_code=404) if lb.status_code != 200: app_logging.logger.warning("Queue poll failed %s: %s", lb.status_code, lb.text[:300]) @@ -123,21 +126,21 @@ async def queue_status(queue_id: str, request: Request) -> JSONResponse: data.get("session_id") or data.get("sessionId"), ) - return JSONResponse(data) + return validated_json_response(data, AllocationResponse) -@router.delete("/api/queue/{queue_id}") -async def queue_leave(queue_id: str) -> dict[str, bool]: +@router.delete("/api/queue/{queue_id}", response_model=QueueCancellationResponse) +async def queue_leave(queue_id: str) -> JSONResponse: """Leave the queue from the explicit 'Leave queue' button (a real fetch).""" if not settings.LOAD_BALANCER_URL: raise HTTPException(status_code=404, detail="No encontrado.") app_logging.logger.debug("[EXT][LB] queue LEAVE (button) queueId=%s", queue_id[:16]) await _lb_leave(queue_id) - return {"ok": True} + return validated_json_response({"ok": True}, QueueCancellationResponse) -@router.post("/api/queue/end") -async def queue_end(request: Request) -> dict[str, bool]: +@router.post("/api/queue/end", response_model=QueueCancellationResponse) +async def queue_end(request: Request) -> JSONResponse: """Leave the queue on teardown/tab-close (navigator.sendBeacon, which can only POST). Body: { queueId }. Best-effort; the LB reaps the ticket on TTL anyway.""" if not settings.LOAD_BALANCER_URL: @@ -146,7 +149,7 @@ async def queue_end(request: Request) -> dict[str, bool]: if qid: app_logging.logger.debug("[EXT][LB] queue LEAVE (teardown) queueId=%s", qid[:16]) await _lb_leave(qid) - return {"ok": True} + return validated_json_response({"ok": True}, QueueCancellationResponse) async def _lb_leave(queue_id: str) -> None: @@ -173,10 +176,10 @@ def _safe_json(response: httpx.Response) -> dict[str, Any]: return body if isinstance(body, dict) else {} -async def _queue_id(request: Request) -> str: +async def _queue_id(request: Request) -> Any: """Pull `queueId` from a JSON body, tolerating sendBeacon's blob posts.""" try: data = await request.json() except Exception: return "" - return (data or {}).get("queueId", "") if isinstance(data, dict) else "" + return QueueEndPayload.model_validate(data).queueId if isinstance(data, dict) else "" diff --git a/s2s_client/core/__init__.py b/s2s-client/s2s_client/core/__init__.py similarity index 100% rename from s2s_client/core/__init__.py rename to s2s-client/s2s_client/core/__init__.py diff --git a/s2s_client/core/config.py b/s2s-client/s2s_client/core/config.py similarity index 99% rename from s2s_client/core/config.py rename to s2s-client/s2s_client/core/config.py index 6c12324..134425d 100644 --- a/s2s_client/core/config.py +++ b/s2s-client/s2s_client/core/config.py @@ -6,7 +6,7 @@ from dotenv import load_dotenv from s2s_client.core import logging as app_logging -ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) load_dotenv(os.path.join(ROOT_DIR, ".env"), override=False) app_logging.logger.debug("Loaded .env from %s", ROOT_DIR) diff --git a/s2s_client/core/diagnostics.py b/s2s-client/s2s_client/core/diagnostics.py similarity index 100% rename from s2s_client/core/diagnostics.py rename to s2s-client/s2s_client/core/diagnostics.py diff --git a/s2s_client/core/logging.py b/s2s-client/s2s_client/core/logging.py similarity index 100% rename from s2s_client/core/logging.py rename to s2s-client/s2s_client/core/logging.py diff --git a/s2s_client/core/urls.py b/s2s-client/s2s_client/core/urls.py similarity index 100% rename from s2s_client/core/urls.py rename to s2s-client/s2s_client/core/urls.py diff --git a/s2s_client/main.py b/s2s-client/s2s_client/main.py similarity index 100% rename from s2s_client/main.py rename to s2s-client/s2s_client/main.py diff --git a/s2s_client/models/__init__.py b/s2s-client/s2s_client/models/__init__.py similarity index 100% rename from s2s_client/models/__init__.py rename to s2s-client/s2s_client/models/__init__.py diff --git a/s2s_client/models/requests.py b/s2s-client/s2s_client/models/requests.py similarity index 74% rename from s2s_client/models/requests.py rename to s2s-client/s2s_client/models/requests.py index add9c87..7cf93b1 100644 --- a/s2s_client/models/requests.py +++ b/s2s-client/s2s_client/models/requests.py @@ -1,6 +1,8 @@ from __future__ import annotations -from pydantic import BaseModel +from typing import Any + +from pydantic import BaseModel, ConfigDict class SearchRequest(BaseModel): @@ -32,3 +34,14 @@ class ApipilotExecuteRequest(BaseModel): name: str arguments: dict = {} + + +class QueueEndPayload(BaseModel): + """A view of a decoded beacon object, preserving the legacy loose value. + + Missing/invalid JSON and non-object bodies are still handled by the route. + In particular, a truthy non-string ID must retain its existing behavior. + """ + + model_config = ConfigDict(extra="allow") + queueId: Any = "" diff --git a/s2s-client/s2s_client/models/responses.py b/s2s-client/s2s_client/models/responses.py new file mode 100644 index 0000000..1c25dbe --- /dev/null +++ b/s2s-client/s2s_client/models/responses.py @@ -0,0 +1,125 @@ +"""Response contracts without imposing new assumptions on upstream JSON. + +Only fields constructed by this service have concrete types. Upstream-owned +values remain Any, and unknown fields remain allowed at every modeled level. +Responses are validated but serialized from their original payloads so omitted +fields, numeric representations, key order, and unknown values stay unchanged. +""" + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, RootModel + + +class OpenResponse(BaseModel): + model_config = ConfigDict(extra="allow", strict=True) + + +class ConfigResponse(OpenResponse): + search: bool + lb: bool + allowDirect: bool + s2sUrl: str + primaryPrompt: str + reasoningType: Literal["PROXY", "OPENAI", "NODE_RED"] + injectProfile: bool + debugPrompt: bool + startSessionMode: str + theme: str + apipilot: bool + + +class SearchResult(OpenResponse): + # Serper values have historically passed through, including nulls, numbers, + # and objects. The service guarantees these keys, not their value types. + title: Any + snippet: Any + url: Any + + +class SearchResponse(OpenResponse): + query: str + answer: Any + results: list[SearchResult] + + +class ReportEntry(OpenResponse): + sourceScheduleLabel: Any + date: Any + + +class ReportsResponse(OpenResponse): + count: int + wrappedLen: int + hints: list[str] + entries: list[ReportEntry] + rawCount: int | None = None + cap: int | None = None + skipped: bool | None = None + reason: str | None = None + truncated: bool | None = None + note: str | None = None + + +class ToolInvocation(OpenResponse): + name: str + success: bool + result_length: int + + +class ReasoningResponse(OpenResponse): + answer: str + log_only: bool | None = None + usage: Any = None + usage_pass2: Any = None + latency_ms: Any = None + intent: Any = None + caller_profile_keys: Any = None + debug: Any = None + apipilot_invoked: ToolInvocation | None = None + + +class ToolDefinition(OpenResponse): + name: str + description: str + parameters: dict[str, Any] + requires_auth: bool + + +class ToolCatalog(OpenResponse): + count: int + include_unauthorized: bool + tools: list[ToolDefinition] + + +class ToolCatalogResponse(OpenResponse): + success: bool + data: ToolCatalog | None + error: Any + + +class ToolExecutionResponse(OpenResponse): + success: bool + # The upstream may omit these fields or return any JSON value for them. + data: Any = None + error: Any = None + + +class AllocationResponse(OpenResponse): + """LB-owned object: no new required fields or value-type restrictions.""" + + state: Any = None + session_id: Any = None + connect_url: Any = None + pending_timeout_s: Any = None + queue_id: Any = None + position: Any = None + poll_interval_s: Any = None + + +class UserProfileResponse(RootModel[Any]): + """The profile endpoint relays objects, arrays, scalars, and null verbatim.""" + + +class QueueCancellationResponse(OpenResponse): + ok: bool diff --git a/s2s_client/providers/__init__.py b/s2s-client/s2s_client/providers/__init__.py similarity index 100% rename from s2s_client/providers/__init__.py rename to s2s-client/s2s_client/providers/__init__.py diff --git a/s2s_client/providers/apipilot.py b/s2s-client/s2s_client/providers/apipilot.py similarity index 100% rename from s2s_client/providers/apipilot.py rename to s2s-client/s2s_client/providers/apipilot.py diff --git a/s2s_client/providers/caller.py b/s2s-client/s2s_client/providers/caller.py similarity index 100% rename from s2s_client/providers/caller.py rename to s2s-client/s2s_client/providers/caller.py diff --git a/s2s_client/realtime/__init__.py b/s2s-client/s2s_client/realtime/__init__.py similarity index 100% rename from s2s_client/realtime/__init__.py rename to s2s-client/s2s_client/realtime/__init__.py diff --git a/s2s_client/realtime/events.py b/s2s-client/s2s_client/realtime/events.py similarity index 100% rename from s2s_client/realtime/events.py rename to s2s-client/s2s_client/realtime/events.py diff --git a/s2s_client/realtime/proxy.py b/s2s-client/s2s_client/realtime/proxy.py similarity index 100% rename from s2s_client/realtime/proxy.py rename to s2s-client/s2s_client/realtime/proxy.py diff --git a/s2s_client/services/__init__.py b/s2s-client/s2s_client/services/__init__.py similarity index 100% rename from s2s_client/services/__init__.py rename to s2s-client/s2s_client/services/__init__.py diff --git a/s2s_client/services/context.py b/s2s-client/s2s_client/services/context.py similarity index 100% rename from s2s_client/services/context.py rename to s2s-client/s2s_client/services/context.py diff --git a/s2s_client/services/reasoning.py b/s2s-client/s2s_client/services/reasoning.py similarity index 99% rename from s2s_client/services/reasoning.py rename to s2s-client/s2s_client/services/reasoning.py index 247b10b..b678c64 100644 --- a/s2s_client/services/reasoning.py +++ b/s2s-client/s2s_client/services/reasoning.py @@ -8,9 +8,11 @@ import httpx from fastapi import HTTPException from fastapi.responses import JSONResponse +from s2s_client.api.responses import validated_json_response from s2s_client.core import config as settings from s2s_client.core import logging as app_logging from s2s_client.models import requests as models_requests +from s2s_client.models.responses import ReasoningResponse from s2s_client.providers import apipilot as providers_apipilot from s2s_client.providers import caller as providers_caller from s2s_client.services import context as services_context @@ -638,9 +640,9 @@ async def reasoning(req: models_requests.ReasoningRequest) -> JSONResponse: final_stripped[:500], json.dumps(out2["apipilot_invoked"], ensure_ascii=True), ) - return JSONResponse(out2) + return validated_json_response(out2, ReasoningResponse) # Without a marker, return the original pass-1 answer. - return JSONResponse(out) + return validated_json_response(out, ReasoningResponse) # ──────────────────────────────────────────────────────────────────── # Mode B: OPENAI calls OpenAI Chat Completions directly. @@ -919,9 +921,9 @@ async def reasoning(req: models_requests.ReasoningRequest) -> JSONResponse: final_oai_ans[:500], json.dumps(final_out_oai["apipilot_invoked"], ensure_ascii=True), ) - return JSONResponse(final_out_oai) + return validated_json_response(final_out_oai, ReasoningResponse) # Without a marker, return the original pass-1 answer. - return JSONResponse({"answer": answer}) + return validated_json_response({"answer": answer}, ReasoningResponse) async def _forward_agent_text_to_nodered(payload: dict) -> None: diff --git a/s2s_client/services/tool_context.py b/s2s-client/s2s_client/services/tool_context.py similarity index 100% rename from s2s_client/services/tool_context.py rename to s2s-client/s2s_client/services/tool_context.py diff --git a/s2s-client/server.py b/s2s-client/server.py index 1d4643c..d35bdba 100644 --- a/s2s-client/server.py +++ b/s2s-client/server.py @@ -4,6 +4,6 @@ import sys from pathlib import Path # Also support --app-dir /path/to/s2s-client when launched outside the checkout. -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parent)) from s2s_client.main import app as app # noqa: E402 diff --git a/tests/fixtures/legacy-contracts.json b/s2s-client/tests/fixtures/legacy-contracts.json similarity index 100% rename from tests/fixtures/legacy-contracts.json rename to s2s-client/tests/fixtures/legacy-contracts.json diff --git a/tests/test_compatibility.py b/s2s-client/tests/test_compatibility.py similarity index 100% rename from tests/test_compatibility.py rename to s2s-client/tests/test_compatibility.py diff --git a/s2s-client/tests/test_models.py b/s2s-client/tests/test_models.py new file mode 100644 index 0000000..e09b750 --- /dev/null +++ b/s2s-client/tests/test_models.py @@ -0,0 +1,71 @@ +"""Validate owned fields without tightening or rewriting upstream payloads.""" + +import pytest +from fastapi.responses import JSONResponse +from pydantic import ValidationError + +from s2s_client.api.responses import validated_json_response +from s2s_client.models.requests import ApipilotExecuteRequest, QueueEndPayload, SearchRequest +from s2s_client.models.responses import ( + AllocationResponse, + QueueCancellationResponse, + ReasoningResponse, + ReportsResponse, + SearchResponse, + ToolExecutionResponse, + UserProfileResponse, +) + + +@pytest.mark.parametrize( + ("model", "payload"), + [ + (AllocationResponse, {}), + (AllocationResponse, {"state": [1, None], "connectUrl": "wss://host", "future": {"x": 1.0}}), + (ToolExecutionResponse, {"success": True, "unknown": {"nested": [None, 1.0, False]}}), + (ToolExecutionResponse, {"success": False, "data": 42, "error": "loose error", "future": []}), + (ReasoningResponse, {"answer": "á", "usage": [1, None], "debug": False, "future": True}), + (SearchResponse, {"query": "q", "answer": {"x": 2}, "results": [{"title": [], "snippet": 7, "url": None}]}), + ( + ReportsResponse, + {"count": 1, "wrappedLen": 2, "hints": [], "entries": [{"sourceScheduleLabel": 42, "date": None}]}, + ), + *[(UserProfileResponse, payload) for payload in [{}, [], 42, False, None, "profile"]], + ], +) +def test_upstream_values_and_missing_fields_keep_exact_wire_bytes(model, payload): + original = JSONResponse(payload, status_code=202) + validated = validated_json_response(payload, model, status_code=202) + assert validated.body == original.body + assert validated.raw_headers == original.raw_headers + assert validated.status_code == original.status_code + + +@pytest.mark.parametrize("payload", [{}, {"ok": "true"}, {"ok": 1}, {"ok": None}]) +def test_owned_response_fields_are_actually_validated(payload): + with pytest.raises(ValidationError): + validated_json_response(payload, QueueCancellationResponse) + + +def test_request_models_keep_legacy_extra_field_and_argument_behavior(): + assert SearchRequest.model_validate({"query": "q", "future": True}).model_dump() == {"query": "q", "key": None} + arguments = {"arbitrary": {"nested": [1, None]}, "unknown": True} + assert ApipilotExecuteRequest.model_validate({"name": "tool", "arguments": arguments}).arguments == arguments + for value in [None, "id", 42, False, [], {}, {"future": True}]: + assert QueueEndPayload.model_validate({"queueId": value, "future": 1}).queueId == value + assert QueueEndPayload.model_validate({}).queueId == "" + + +def test_http_routes_publish_response_schemas(): + # Import the app only after the compatibility suite's isolated configuration. + from s2s_client.main import app + + spec = app.openapi() + for path, operations in spec["paths"].items(): + if path.startswith("/api/"): + for operation in operations.values(): + schema = operation["responses"]["200"]["content"]["application/json"]["schema"] + assert "$ref" in schema, path + allocation = spec["components"]["schemas"]["AllocationResponse"] + assert not allocation.get("required") + assert allocation["additionalProperties"] is True diff --git a/tests/test_server.py b/s2s-client/tests/test_server.py similarity index 99% rename from tests/test_server.py rename to s2s-client/tests/test_server.py index 10cae49..3c9fe3b 100644 --- a/tests/test_server.py +++ b/s2s-client/tests/test_server.py @@ -1,6 +1,6 @@ """Backend contract checks with local HTTP fixtures and a loopback voice server. -Run from the repository root: uv run --locked pytest tests +Run from s2s-client: uv run --locked pytest tests """ import json diff --git a/uv.lock b/s2s-client/uv.lock similarity index 100% rename from uv.lock rename to s2s-client/uv.lock