refactor(api): scope uv to s2s-client and validate compatible responses
This commit is contained in:
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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 = ["."]
|
||||||
|
|
||||||
@@ -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 = ""
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
Generated
Reference in New Issue
Block a user