refactor(api): scope uv to s2s-client and validate compatible responses

This commit is contained in:
2026-10-06 18:23:08 -03:00
parent 92c53dc7ca
commit f8d5b7857a
39 changed files with 316 additions and 67 deletions
@@ -7,19 +7,21 @@ repos:
hooks: hooks:
- id: ruff-check - id: ruff-check
name: Ruff lint name: Ruff lint
entry: uv run --locked ruff check --force-exclude entry: uv run --project s2s-client --locked ruff check --force-exclude
language: unsupported language: unsupported
types_or: [python, pyi] types_or: [python, pyi]
files: ^s2s-client/
require_serial: true require_serial: true
- id: ruff-format - id: ruff-format
name: Ruff formatting 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 language: unsupported
types_or: [python, pyi] types_or: [python, pyi]
files: ^s2s-client/
require_serial: true require_serial: true
- id: mypy - id: mypy
name: Python types name: Python types
entry: uv run --locked mypy entry: uv run --directory s2s-client --locked mypy
language: unsupported language: unsupported
pass_filenames: false 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)
+4 -4
View File
@@ -45,16 +45,16 @@ test:
check: lock-check format-check lint type-check test check: lock-check format-check lint type-check test
dev: dev:
./start_ui.sh ../start_ui.sh
hooks-install: 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: 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: test-launcher:
npm --prefix s2s-frontend run test:launcher npm --prefix ../s2s-frontend run test:launcher
test-image: test-image:
bash scripts/test_docker_image.sh bash scripts/test_docker_image.sh
+4 -5
View File
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
name = "aquaportal-s2s-client" name = "aquaportal-s2s-client"
version = "0.1.0" version = "0.1.0"
description = "FastAPI integration and realtime proxy for the Vocero frontend" description = "FastAPI integration and realtime proxy for the Vocero frontend"
readme = "s2s-client/README.md" readme = "README.md"
requires-python = ">=3.12,<3.13" requires-python = ">=3.12,<3.13"
dependencies = [ dependencies = [
"fastapi==0.115.6", "fastapi==0.115.6",
@@ -34,7 +34,7 @@ include = ["s2s_client*"]
[tool.ruff] [tool.ruff]
target-version = "py312" target-version = "py312"
line-length = 120 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] [tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I", "B", "UP"] select = ["E4", "E7", "E9", "F", "I", "B", "UP"]
@@ -48,7 +48,7 @@ line-ending = "auto"
[tool.mypy] [tool.mypy]
python_version = "3.12" python_version = "3.12"
plugins = ["pydantic.mypy"] plugins = ["pydantic.mypy"]
files = ["s2s_client", "s2s-client/server.py"] files = ["s2s_client", "server.py"]
check_untyped_defs = true check_untyped_defs = true
disallow_untyped_defs = true disallow_untyped_defs = true
no_implicit_optional = true no_implicit_optional = true
@@ -66,5 +66,4 @@ warn_untyped_fields = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = [".", "s2s-client"] pythonpath = ["."]
+18
View File
@@ -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)
@@ -3,13 +3,14 @@
from fastapi import APIRouter from fastapi import APIRouter
from s2s_client.api.routes import apipilot, caller, config, search, sessions 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.realtime.proxy import realtime_proxy
from s2s_client.services.reasoning import reasoning from s2s_client.services.reasoning import reasoning
router = APIRouter() router = APIRouter()
router.include_router(config.router) router.include_router(config.router)
router.include_router(apipilot.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.add_api_websocket_route("/v1/realtime", realtime_proxy)
router.include_router(caller.router) router.include_router(caller.router)
router.include_router(search.router) router.include_router(search.router)
@@ -1,24 +1,24 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse 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 config as settings
from s2s_client.core import logging as app_logging from s2s_client.core import logging as app_logging
from s2s_client.models import requests as models_requests 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 from s2s_client.providers import apipilot as providers_apipilot
router = APIRouter() router = APIRouter()
@router.get("/api/apipilot/tools", response_model=None) @router.get("/api/apipilot/tools", response_model=ToolCatalogResponse)
async def apipilot_list_tools( async def apipilot_list_tools(
request: Request, request: Request,
include_unauthorized: bool = True, include_unauthorized: bool = True,
refresh: bool = False, refresh: bool = False,
) -> dict[str, Any] | JSONResponse: ) -> JSONResponse:
"""Public proxy for ApiPilot tool discovery (guide §2.1). """Public proxy for ApiPilot tool discovery (guide §2.1).
Forwards Authorization / x-demo-session headers from the caller to the 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` 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).""" (e.g. after a user just logged in and previously saw only requires_auth tools)."""
if not settings.S2S_APIPILOT_TOOLS_URL: if not settings.S2S_APIPILOT_TOOLS_URL:
return JSONResponse( return validated_json_response(
model=ToolCatalogResponse,
status_code=501, status_code=501,
content={ content={
"success": False, "success": False,
@@ -64,7 +65,8 @@ async def apipilot_list_tools(
"raw": None, "raw": None,
} }
) )
return JSONResponse( return validated_json_response(
model=ToolCatalogResponse,
status_code=exc.status_code, status_code=exc.status_code,
content={"success": False, "data": None, "error": detail}, content={"success": False, "data": None, "error": detail},
) )
@@ -74,18 +76,21 @@ async def apipilot_list_tools(
bool(include_unauthorized), bool(include_unauthorized),
bool(refresh), bool(refresh),
) )
return { return validated_json_response(
"success": True, {
"data": { "success": True,
"count": len(tools), "data": {
"include_unauthorized": bool(include_unauthorized), "count": len(tools),
"tools": 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: async def apipilot_execute(request: Request, body: models_requests.ApipilotExecuteRequest) -> JSONResponse:
"""Public proxy for ApiPilot /execute (guide §2.2). """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` 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).""" and `error.raw` are for internal auditing (guide §2.2, recommended UI errors)."""
if not settings.S2S_APIPILOT_TOOLS_URL: if not settings.S2S_APIPILOT_TOOLS_URL:
return JSONResponse( return validated_json_response(
model=ToolExecutionResponse,
status_code=501, status_code=501,
content={ content={
"success": False, "success": False,
@@ -138,4 +144,4 @@ async def apipilot_execute(request: Request, body: models_requests.ApipilotExecu
envelope.get("success"), envelope.get("success"),
status_code, status_code,
) )
return JSONResponse(status_code=status_code, content=envelope) return validated_json_response(envelope, ToolExecutionResponse, status_code=status_code)
@@ -6,15 +6,17 @@ import httpx
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse 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 config as settings
from s2s_client.core import logging as app_logging 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.providers import caller as providers_caller
from s2s_client.services import context as services_context from s2s_client.services import context as services_context
router = APIRouter() router = APIRouter()
@router.get("/api/user/phone") @router.get("/api/user/phone", response_model=UserProfileResponse)
async def user_by_phone(phoneNumber: str) -> JSONResponse: async def user_by_phone(phoneNumber: str) -> JSONResponse:
"""HTTP passthrough: fetches caller identity / profile from the external """HTTP passthrough: fetches caller identity / profile from the external
info endpoint set via VOCERO_USER_PHONE_AUTH_URL and forwards the raw 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, status_code=502,
detail="El servicio de información del usuario devolvió un JSON no válido.", 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: async def user_reports(phoneNumber: str | None = None) -> JSONResponse:
"""Fetch + normalize the scheduled reports list for the caller UI. """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) ───────────────────────── # ── MASS-INJECTION KILL SWITCH (user request) ─────────────────────────
if not settings.S2S_INJECT_REPORTS: if not settings.S2S_INJECT_REPORTS:
return JSONResponse( return validated_json_response(
model=ReportsResponse,
status_code=200, status_code=200,
content={ content={
"count": 0, "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. # guarantees UI and LLM see exactly the same filtered/sorted list.
ctx_str = services_context._resolve_reports_context(reports_raw) ctx_str = services_context._resolve_reports_context(reports_raw)
if not ctx_str or not isinstance(reports_raw, list): if not ctx_str or not isinstance(reports_raw, list):
return JSONResponse( return validated_json_response(
{ {
"count": 0, "count": 0,
"wrappedLen": 0, "wrappedLen": 0,
@@ -153,7 +158,8 @@ async def user_reports(phoneNumber: str | None = None) -> JSONResponse:
"entries": [], "entries": [],
"skipped": True, "skipped": True,
"reason": "Lista vacía, error del servicio de origen o ningún contenido disponible.", "reason": "Lista vacía, error del servicio de origen o ningún contenido disponible.",
} },
ReportsResponse,
) )
# Rebuild hints + normalized entries with the exact filtered output # Rebuild hints + normalized entries with the exact filtered output
items_list: list = reports_raw 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: if truncated_ui_note is not None:
resp_body["truncated"] = True resp_body["truncated"] = True
resp_body["note"] = truncated_ui_note resp_body["note"] = truncated_ui_note
return JSONResponse(resp_body) return validated_json_response(resp_body, ReportsResponse)
@@ -1,12 +1,13 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Request 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 config as settings
from s2s_client.core import logging as app_logging from s2s_client.core import logging as app_logging
from s2s_client.core import urls as core_urls from s2s_client.core import urls as core_urls
from s2s_client.models.responses import ConfigResponse
router = APIRouter() 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) return settings.SPEECH_TO_SPEECH_URL or core_urls._proxy_public_url(request)
@router.get("/api/config") @router.get("/api/config", response_model=ConfigResponse)
async def config(request: Request) -> dict[str, Any]: async def config(request: Request) -> JSONResponse:
"""Client bootstrap: whether web search is available, whether the deploy runs """Client bootstrap: whether web search is available, whether the deploy runs
behind a load balancer (so the browser uses the /api/session proxy), behind a load balancer (so the browser uses the /api/session proxy),
and whether the user may instead set a direct 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 "-", settings.S2S_THEME or "-",
out["apipilot"], out["apipilot"],
) )
return out return validated_json_response(out, ConfigResponse)
@@ -6,14 +6,16 @@ import httpx
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse 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 config as settings
from s2s_client.core import logging as app_logging from s2s_client.core import logging as app_logging
from s2s_client.models import requests as models_requests from s2s_client.models import requests as models_requests
from s2s_client.models.responses import SearchResponse
router = APIRouter() router = APIRouter()
@router.post("/api/search") @router.post("/api/search", response_model=SearchResponse)
async def search(req: models_requests.SearchRequest) -> JSONResponse: async def search(req: models_requests.SearchRequest) -> JSONResponse:
"""Proxy a Google search via Serper.dev. The key stays on the server unless """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).""" 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), len(results),
[r.get("title", "")[:40] for r in 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)
@@ -6,13 +6,16 @@ import httpx
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse 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 config as settings
from s2s_client.core import logging as app_logging 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 = APIRouter()
@router.post("/api/session") @router.post("/api/session", response_model=AllocationResponse)
async def session(request: Request) -> JSONResponse: async def session(request: Request) -> JSONResponse:
"""Relay a session grant or queue ticket, keeping the LB address server-side. """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) body = _safe_json(lb)
if body.get("state") == "at_capacity": if body.get("state") == "at_capacity":
app_logging.logger.debug("[EXT][LB] session: at_capacity — queue full") 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: if lb.status_code != 200:
# The LB's error body may name the reason (e.g. capacity); it carries no # 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("queue_id") or data.get("queueId"),
data.get("position"), 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: async def queue_status(queue_id: str, request: Request) -> JSONResponse:
"""Relay a waiting ticket's position or its newly claimed session grant.""" """Relay a waiting ticket's position or its newly claimed session grant."""
client_ip = request.client.host if request.client else "?" 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 # Ticket unknown/expired (reaped after we stopped polling). Tell the client
# to start over rather than spin. # to start over rather than spin.
app_logging.logger.debug("[EXT][LB] queue: queueId=%s EXPIRED (404)", queue_id[:16]) 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: if lb.status_code != 200:
app_logging.logger.warning("Queue poll failed %s: %s", lb.status_code, lb.text[:300]) 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"), data.get("session_id") or data.get("sessionId"),
) )
return JSONResponse(data) return validated_json_response(data, AllocationResponse)
@router.delete("/api/queue/{queue_id}") @router.delete("/api/queue/{queue_id}", response_model=QueueCancellationResponse)
async def queue_leave(queue_id: str) -> dict[str, bool]: async def queue_leave(queue_id: str) -> JSONResponse:
"""Leave the queue from the explicit 'Leave queue' button (a real fetch).""" """Leave the queue from the explicit 'Leave queue' button (a real fetch)."""
if not settings.LOAD_BALANCER_URL: if not settings.LOAD_BALANCER_URL:
raise HTTPException(status_code=404, detail="No encontrado.") raise HTTPException(status_code=404, detail="No encontrado.")
app_logging.logger.debug("[EXT][LB] queue LEAVE (button) queueId=%s", queue_id[:16]) app_logging.logger.debug("[EXT][LB] queue LEAVE (button) queueId=%s", queue_id[:16])
await _lb_leave(queue_id) await _lb_leave(queue_id)
return {"ok": True} return validated_json_response({"ok": True}, QueueCancellationResponse)
@router.post("/api/queue/end") @router.post("/api/queue/end", response_model=QueueCancellationResponse)
async def queue_end(request: Request) -> dict[str, bool]: async def queue_end(request: Request) -> JSONResponse:
"""Leave the queue on teardown/tab-close (navigator.sendBeacon, which can only """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.""" POST). Body: { queueId }. Best-effort; the LB reaps the ticket on TTL anyway."""
if not settings.LOAD_BALANCER_URL: if not settings.LOAD_BALANCER_URL:
@@ -146,7 +149,7 @@ async def queue_end(request: Request) -> dict[str, bool]:
if qid: if qid:
app_logging.logger.debug("[EXT][LB] queue LEAVE (teardown) queueId=%s", qid[:16]) app_logging.logger.debug("[EXT][LB] queue LEAVE (teardown) queueId=%s", qid[:16])
await _lb_leave(qid) await _lb_leave(qid)
return {"ok": True} return validated_json_response({"ok": True}, QueueCancellationResponse)
async def _lb_leave(queue_id: str) -> None: 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 {} 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.""" """Pull `queueId` from a JSON body, tolerating sendBeacon's blob posts."""
try: try:
data = await request.json() data = await request.json()
except Exception: except Exception:
return "" return ""
return (data or {}).get("queueId", "") if isinstance(data, dict) else "" return QueueEndPayload.model_validate(data).queueId if isinstance(data, dict) else ""
@@ -6,7 +6,7 @@ from dotenv import load_dotenv
from s2s_client.core import logging as app_logging 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) load_dotenv(os.path.join(ROOT_DIR, ".env"), override=False)
app_logging.logger.debug("Loaded .env from %s", ROOT_DIR) app_logging.logger.debug("Loaded .env from %s", ROOT_DIR)
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
from pydantic import BaseModel from typing import Any
from pydantic import BaseModel, ConfigDict
class SearchRequest(BaseModel): class SearchRequest(BaseModel):
@@ -32,3 +34,14 @@ class ApipilotExecuteRequest(BaseModel):
name: str name: str
arguments: dict = {} 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 = ""
+125
View File
@@ -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
@@ -8,9 +8,11 @@ import httpx
from fastapi import HTTPException from fastapi import HTTPException
from fastapi.responses import JSONResponse 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 config as settings
from s2s_client.core import logging as app_logging from s2s_client.core import logging as app_logging
from s2s_client.models import requests as models_requests 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 apipilot as providers_apipilot
from s2s_client.providers import caller as providers_caller from s2s_client.providers import caller as providers_caller
from s2s_client.services import context as services_context from s2s_client.services import context as services_context
@@ -638,9 +640,9 @@ async def reasoning(req: models_requests.ReasoningRequest) -> JSONResponse:
final_stripped[:500], final_stripped[:500],
json.dumps(out2["apipilot_invoked"], ensure_ascii=True), 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. # 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. # Mode B: OPENAI calls OpenAI Chat Completions directly.
@@ -919,9 +921,9 @@ async def reasoning(req: models_requests.ReasoningRequest) -> JSONResponse:
final_oai_ans[:500], final_oai_ans[:500],
json.dumps(final_out_oai["apipilot_invoked"], ensure_ascii=True), 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. # 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: async def _forward_agent_text_to_nodered(payload: dict) -> None:
+1 -1
View File
@@ -4,6 +4,6 @@ import sys
from pathlib import Path from pathlib import Path
# Also support --app-dir /path/to/s2s-client when launched outside the checkout. # 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 from s2s_client.main import app as app # noqa: E402
+71
View File
@@ -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
@@ -1,6 +1,6 @@
"""Backend contract checks with local HTTP fixtures and a loopback voice server. """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 import json
View File