refactor(api): scope uv to s2s-client and validate compatible responses
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 = ["."]
|
||||
@@ -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 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 ""
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = ""
|
||||
@@ -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.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:
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Run from the repository root: uv run --locked pytest tests
|
||||
Run from s2s-client: uv run --locked pytest tests
|
||||
"""
|
||||
|
||||
import json
|
||||
Generated
Reference in New Issue
Block a user