Vocero now retrieves AquaPortal content through authenticated MCP. This removes the obsolete ApiPilot integration from the API and browser while preserving the separately deployed speech pipeline. Release notes: - Removed ApiPilot endpoints, configuration, credential overrides, and HTTP/realtime tool execution. - Removed browser ApiPilot catalog loading and prompt generation while retaining authenticated MCP discovery. - Updated regression coverage and operating documentation for the retired routes and settings. Reviewed-on: AquaPortal/s2s#3
490 lines
17 KiB
Python
490 lines
17 KiB
Python
"""Golden wire behavior captured before the tooling/structure migration.
|
|
|
|
Fixtures include permissive payloads and existing failures deliberately. Changing
|
|
them requires a separate, explicitly approved API behavior change.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import httpx
|
|
from fastapi.testclient import TestClient
|
|
|
|
with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_value=False):
|
|
from vocero_api import main as server
|
|
from vocero_api.core import config as settings
|
|
from vocero_api.realtime import proxy
|
|
from vocero_api.services import context
|
|
|
|
ASYNC_CLIENT = httpx.AsyncClient
|
|
FIXTURE = Path(__file__).parent / "fixtures" / "legacy-contracts.json"
|
|
BASE_CONFIG = {
|
|
"LOAD_BALANCER_URL": "",
|
|
"SPEECH_TO_SPEECH_URL": "",
|
|
"S2S_PROXY_TARGET": "",
|
|
"S2S_PRIMARY_PROMPT": "",
|
|
"SERPER_KEY": "",
|
|
"S2S_REASONING_TYPE": "PROXY",
|
|
"S2S_INJECT_PROFILE": False,
|
|
"S2S_INJECT_REPORTS": False,
|
|
"S2S_DEBUG_PROMPT": False,
|
|
"S2S_START_SESSION_MODE": "",
|
|
"S2S_THEME": "",
|
|
"VOCERO_USER_PHONE_AUTH_URL": "",
|
|
"S2S_REPORT_LIST": "",
|
|
"S2S_LOGIN_BY_PHONE": "",
|
|
"S2S_LATEST_SUMMARY": "",
|
|
"S2S_LATEST_SUMMARY_TEXT": "",
|
|
"OPENAI_API_KEY": "",
|
|
"S2S_REPORTS_QUICK_HINT_MAX": 5,
|
|
"S2S_REPORTS_INJECT_MODE": "minimal",
|
|
}
|
|
|
|
|
|
def cases():
|
|
"""Each case controls all upstream IO; no deployment services are contacted."""
|
|
yield "config-default", "GET", "/api/config", {}, {}, []
|
|
yield (
|
|
"config-proxy",
|
|
"GET",
|
|
"/api/config",
|
|
{
|
|
"headers": {"x-forwarded-proto": "https", "x-forwarded-host": "voice.test, internal.test"},
|
|
},
|
|
{"S2S_PROXY_TARGET": "ws://private.test/voice"},
|
|
[],
|
|
)
|
|
yield (
|
|
"config-direct",
|
|
"GET",
|
|
"/api/config",
|
|
{},
|
|
{"SPEECH_TO_SPEECH_URL": "wss://voice.test/realtime?token=fixture"},
|
|
[],
|
|
)
|
|
yield "config-lb", "GET", "/api/config", {}, {"LOAD_BALANCER_URL": "http://lb.test"}, []
|
|
for path in ("/api/me", "/api/session/heartbeat", "/api/session/end", "/", "/server.py"):
|
|
yield f"removed-{path}", "GET", path, {}, {}, []
|
|
for path in ("/project", "/project/", "/project/index.html", "/public/docs.html"):
|
|
yield f"redirect-{path}", "GET", path, {}, {}, []
|
|
for path in ("/api/session", "/api/queue/end"):
|
|
yield f"disabled-{path}", "POST", path, {}, {}, []
|
|
for method in ("GET", "DELETE"):
|
|
yield f"disabled-queue-{method}", method, "/api/queue/ticket", {}, {}, []
|
|
|
|
for name, payload in (
|
|
("missing", {}),
|
|
("blank", {"query": " "}),
|
|
("wrong-type", {"query": 17}),
|
|
("null", {"query": None}),
|
|
("unconfigured", {"query": "hello"}),
|
|
):
|
|
yield f"search-{name}", "POST", "/api/search", {"json": payload}, {}, []
|
|
search_config = {"SERPER_KEY": "server-fixture-key"}
|
|
yield (
|
|
"search-success-extra-fields",
|
|
"POST",
|
|
"/api/search",
|
|
{
|
|
"json": {
|
|
"query": " hello ",
|
|
"key": "caller-fixture-key",
|
|
"unknown_request_field": [1, 2],
|
|
}
|
|
},
|
|
search_config,
|
|
[
|
|
(
|
|
200,
|
|
{
|
|
"answerBox": {"answer": "Answer"},
|
|
"organic": [
|
|
{"title": "Title", "snippet": "Text", "link": "https://example.test", "unknown": 1},
|
|
],
|
|
"future_upstream": {"nested": True},
|
|
},
|
|
)
|
|
],
|
|
)
|
|
yield (
|
|
"search-loose-values",
|
|
"POST",
|
|
"/api/search",
|
|
{"json": {"query": "hello"}},
|
|
search_config,
|
|
[
|
|
(
|
|
200,
|
|
{
|
|
"answerBox": {"answer": {"future": [1, None]}},
|
|
"organic": [{"title": None, "snippet": 3, "link": {"href": "unusual"}}, {}],
|
|
},
|
|
)
|
|
],
|
|
)
|
|
yield (
|
|
"search-upstream-error",
|
|
"POST",
|
|
"/api/search",
|
|
{"json": {"query": "hello"}},
|
|
search_config,
|
|
[(429, {"message": "Fixture credits exhausted"})],
|
|
)
|
|
yield (
|
|
"search-invalid-upstream-json",
|
|
"POST",
|
|
"/api/search",
|
|
{"json": {"query": "hello"}},
|
|
search_config,
|
|
[(200, b"not-json")],
|
|
)
|
|
|
|
yield "profile-missing-phone", "GET", "/api/user/phone", {}, {}, []
|
|
yield "profile-unconfigured", "GET", "/api/user/phone?phoneNumber=123", {}, {}, []
|
|
profile_config = {"VOCERO_USER_PHONE_AUTH_URL": "http://profile.test/user"}
|
|
yield "profile-blank-phone", "GET", "/api/user/phone?phoneNumber=%20", {}, profile_config, []
|
|
for name, status, payload in (
|
|
("object", 200, {"result": {"name": "Caller", "unknown": {"nested": [1, None]}}}),
|
|
("array", 200, [1, {"future": "field"}]),
|
|
("null", 200, None),
|
|
("string", 200, "opaque profile"),
|
|
("error", 403, {"future_error": True}),
|
|
("invalid-json", 200, b"not-json"),
|
|
):
|
|
yield (
|
|
f"profile-{name}",
|
|
"GET",
|
|
"/api/user/phone?phoneNumber=%2B56000000000",
|
|
{},
|
|
profile_config,
|
|
[(status, payload)],
|
|
)
|
|
yield (
|
|
"profile-login-token",
|
|
"GET",
|
|
"/api/user/phone?phoneNumber=123",
|
|
{},
|
|
{
|
|
**profile_config,
|
|
"S2S_LOGIN_BY_PHONE": "http://profile.test/login",
|
|
},
|
|
[(200, {"result": {"data": {"accessToken": "fixture-token", "future": True}}}), (200, {"name": "Caller"})],
|
|
)
|
|
|
|
yield "reports-disabled", "GET", "/api/user/reports", {}, {}, []
|
|
yield "reports-unconfigured", "GET", "/api/user/reports", {}, {"S2S_INJECT_REPORTS": True}, []
|
|
reports_config = {"S2S_INJECT_REPORTS": True, "S2S_REPORT_LIST": "http://reports.test/list"}
|
|
yield "reports-empty", "GET", "/api/user/reports", {}, reports_config, [(200, [])]
|
|
yield "reports-upstream-failure", "GET", "/api/user/reports", {}, reports_config, [(503, {"error": "fixture"})]
|
|
yield (
|
|
"reports-normalization",
|
|
"GET",
|
|
"/api/user/reports?phoneNumber=123",
|
|
{},
|
|
reports_config,
|
|
[
|
|
(
|
|
200,
|
|
{
|
|
"result": [
|
|
{
|
|
"sourceScheduleLabel": "Daily",
|
|
"date": "2026-01-02T12:00:00Z",
|
|
"podcastScript": "private script",
|
|
"future": True,
|
|
},
|
|
{
|
|
"sourceScheduleLabel": "Daily",
|
|
"date": "2026-01-01T12:00:00Z",
|
|
"podcastScript": "older script",
|
|
},
|
|
{"sourceSchedule": {"name": "Weekly", "date": "2026-01-03T12:00:00Z"}, "text": "weekly script"},
|
|
],
|
|
"future": True,
|
|
},
|
|
)
|
|
],
|
|
)
|
|
|
|
lb_config = {"LOAD_BALANCER_URL": "http://lb.test"}
|
|
for name, status, payload in (
|
|
(
|
|
"grant",
|
|
200,
|
|
{"session_id": "session", "connect_url": "ws://voice.test?token=fixture", "future": {"nested": True}},
|
|
),
|
|
("camelcase", 200, {"sessionId": "session", "connectUrl": "ws://voice.test", "unknown": None}),
|
|
("queued", 200, {"state": "queued", "queue_id": "ticket", "position": 3, "future": [1]}),
|
|
("empty", 200, {}),
|
|
("capacity", 503, {"state": "at_capacity", "future": True}),
|
|
("other-error", 429, {"error": "fixture"}),
|
|
("invalid-json", 200, b"not-json"),
|
|
):
|
|
yield (
|
|
f"session-{name}",
|
|
"POST",
|
|
"/api/session",
|
|
{"content": b"arbitrary ignored request body"},
|
|
lb_config,
|
|
[(status, payload)],
|
|
)
|
|
for name, status, payload in (
|
|
("waiting", 200, {"state": "queued", "queue_id": "ticket", "position": None, "future": True}),
|
|
("grant", 200, {"session_id": "session", "connect_url": "ws://voice.test"}),
|
|
("expired", 404, {"unknown": True}),
|
|
("error", 500, {}),
|
|
("invalid-json", 200, b"bad"),
|
|
):
|
|
yield f"queue-{name}", "GET", "/api/queue/ticket", {}, lb_config, [(status, payload)]
|
|
yield "queue-delete", "DELETE", "/api/queue/ticket", {}, lb_config, [(500, {})]
|
|
for name, kwargs, upstream in (
|
|
("valid", {"json": {"queueId": "ticket", "future": True}}, [(204, b"")]),
|
|
("empty", {"content": b""}, []),
|
|
("invalid-json", {"content": b"bad"}, []),
|
|
("array", {"json": ["ticket"]}, []),
|
|
("null-id", {"json": {"queueId": None}}, []),
|
|
("numeric-id", {"json": {"queueId": 42}}, []),
|
|
):
|
|
yield f"queue-end-{name}", "POST", "/api/queue/end", kwargs, lb_config, upstream
|
|
|
|
for name, payload in (("missing", {}), ("proxy", {"transcript": "hello"}), ("proxy-blank", {"transcript": " "})):
|
|
yield f"reasoning-{name}", "POST", "/api/reasoning", {"json": payload}, {}, []
|
|
yield (
|
|
"reasoning-openai-profile-disabled",
|
|
"POST",
|
|
"/api/reasoning",
|
|
{"json": {"transcript": "hello"}},
|
|
{"S2S_REASONING_TYPE": "OPENAI"},
|
|
[],
|
|
)
|
|
yield (
|
|
"reasoning-legacy-unbound-profile",
|
|
"POST",
|
|
"/api/reasoning",
|
|
{"json": {"transcript": "hello"}},
|
|
{
|
|
"S2S_REASONING_TYPE": "OPENAI",
|
|
"S2S_INJECT_PROFILE": True,
|
|
"S2S_LATEST_SUMMARY_TEXT": "fixture summary",
|
|
},
|
|
[],
|
|
)
|
|
reasoning_config = {
|
|
**profile_config,
|
|
"S2S_INJECT_PROFILE": True,
|
|
"S2S_REASONING_TYPE": "OPENAI",
|
|
}
|
|
reasoning_request = {
|
|
"json": {
|
|
"transcript": " hello ",
|
|
"phoneNumber": "123",
|
|
"instructions": "Fixture instructions",
|
|
"language": "es",
|
|
"future": True,
|
|
}
|
|
}
|
|
yield (
|
|
"reasoning-openai-answer",
|
|
"POST",
|
|
"/api/reasoning",
|
|
reasoning_request,
|
|
{
|
|
**reasoning_config,
|
|
"OPENAI_API_KEY": "fixture-key",
|
|
},
|
|
[
|
|
(200, {"name": "Caller"}),
|
|
(
|
|
200,
|
|
{
|
|
"choices": [{"message": {"content": " answer ", "future": True}}],
|
|
"usage": {"total_tokens": 12},
|
|
"future": True,
|
|
},
|
|
),
|
|
],
|
|
)
|
|
|
|
|
|
def capture_http():
|
|
captures = {}
|
|
for name, method, path, kwargs, config, responses in cases():
|
|
remaining = iter(responses)
|
|
requests = []
|
|
|
|
def upstream(request, *, requests=requests, remaining=remaining):
|
|
requests.append(
|
|
{
|
|
"method": request.method,
|
|
"url": str(request.url),
|
|
"body": request.content.decode(),
|
|
"headers": {
|
|
key: request.headers[key]
|
|
for key in (
|
|
"authorization",
|
|
"x-demo-session",
|
|
"x-api-key",
|
|
"content-type",
|
|
)
|
|
if key in request.headers
|
|
},
|
|
}
|
|
)
|
|
status, payload = next(remaining)
|
|
if isinstance(payload, bytes):
|
|
return httpx.Response(status, content=payload)
|
|
return httpx.Response(
|
|
status, content=json.dumps(payload).encode(), headers={"content-type": "application/json"}
|
|
)
|
|
|
|
with (
|
|
patch.multiple(settings, **{**BASE_CONFIG, **config}),
|
|
patch.object(context, "_build_today_fixed_context_block", return_value="Fixture current date: 2026-01-04"),
|
|
patch.object(
|
|
httpx,
|
|
"AsyncClient",
|
|
side_effect=lambda **opts: ASYNC_CLIENT(transport=httpx.MockTransport(upstream), **opts),
|
|
),
|
|
TestClient(server.app, raise_server_exceptions=False) as client,
|
|
):
|
|
response = client.request(method, path, follow_redirects=False, **kwargs)
|
|
captures[name] = {
|
|
"status": response.status_code,
|
|
"body": response.text,
|
|
"headers": {
|
|
key: response.headers[key]
|
|
for key in ("content-type", "location", "set-cookie")
|
|
if key in response.headers
|
|
},
|
|
"upstream": requests,
|
|
}
|
|
if list(remaining):
|
|
raise AssertionError(f"Unused upstream fixtures in {name}")
|
|
return captures
|
|
|
|
|
|
def capture_openapi_requests():
|
|
schema = server.app.openapi()
|
|
return {
|
|
"operations": {
|
|
f"{method.upper()} {path}": {
|
|
key: operation[key] for key in ("operationId", "parameters", "requestBody") if key in operation
|
|
}
|
|
for path, methods in schema["paths"].items()
|
|
for method, operation in methods.items()
|
|
},
|
|
"models": {
|
|
name: schema["components"]["schemas"][name]
|
|
for name in (
|
|
"SearchRequest",
|
|
"ReasoningRequest",
|
|
"HTTPValidationError",
|
|
"ValidationError",
|
|
)
|
|
},
|
|
}
|
|
|
|
|
|
async def capture_realtime():
|
|
frames = []
|
|
|
|
class Socket:
|
|
async def send_text(self, message):
|
|
frames.append(message)
|
|
|
|
async def send_bytes(self, message):
|
|
frames.append({"binary": message.hex()})
|
|
|
|
async def upstream():
|
|
yield b"\x00\x01\x02\x03"
|
|
yield json.dumps({"type": "future.event", "future": {"nested": [None, 1]}})
|
|
yield json.dumps({"type": "response.text.done", "text": "Hello [rightspeak]world[endspeak]", "future": True})
|
|
yield json.dumps({"type": "error", "error": {"message": "Unknown or invalid event: session.update"}})
|
|
yield json.dumps({"type": "future.after.suppressed.error"})
|
|
|
|
await proxy._upstream_to_client(Socket(), upstream(), "fixture", {})
|
|
return frames
|
|
|
|
|
|
def capture():
|
|
return {
|
|
"http": capture_http(),
|
|
"openapi_requests": capture_openapi_requests(),
|
|
"realtime": asyncio.run(capture_realtime()),
|
|
}
|
|
|
|
|
|
class LegacyCompatibilityTests(unittest.TestCase):
|
|
def test_native_transcripts_are_relayed_without_external_http_calls(self):
|
|
events = [
|
|
{"type": "conversation.item.input_audio_transcription.completed", "transcript": "Hola", "item_id": "u1"},
|
|
{"type": "response.audio_transcript.done", "transcript": "Respuesta", "response_id": "r1"},
|
|
{"type": "response.output_audio_transcript.done", "transcript": "Respuesta", "response_id": "r1"},
|
|
{"type": "response.content_part.done", "part": {"type": "text", "text": "Respuesta"}},
|
|
*[
|
|
{
|
|
"type": event_type,
|
|
"item": {"id": "a1", "role": "assistant", "content": [{"type": "text", "text": "Respuesta"}]},
|
|
}
|
|
for event_type in ("response.output_item.done", "conversation.item.created")
|
|
],
|
|
{"type": "response.text.done", "text": "Respuesta", "response_id": "r1"},
|
|
]
|
|
messages = [json.dumps(event) for event in events]
|
|
forwarded = []
|
|
|
|
class Socket:
|
|
async def send_text(self, message):
|
|
forwarded.append(message)
|
|
|
|
async def upstream():
|
|
for message in messages:
|
|
yield message
|
|
|
|
async def relay():
|
|
await proxy._upstream_to_client(Socket(), upstream(), "fixture", {})
|
|
# Allow any accidentally scheduled background HTTP work to run.
|
|
await asyncio.sleep(0)
|
|
|
|
for mode in ("PROXY", "OPENAI"):
|
|
forwarded.clear()
|
|
with (
|
|
self.subTest(mode=mode),
|
|
patch.multiple(settings, **{**BASE_CONFIG, "S2S_REASONING_TYPE": mode}),
|
|
patch.object(httpx, "AsyncClient", side_effect=AssertionError("Unexpected HTTP call")) as http,
|
|
):
|
|
asyncio.run(relay())
|
|
self.assertEqual(forwarded, messages)
|
|
http.assert_not_called()
|
|
|
|
def test_wire_contracts_match_pre_migration_capture(self):
|
|
expected = json.loads(FIXTURE.read_text())
|
|
actual = capture()
|
|
for name, response in actual["http"].items():
|
|
with self.subTest(case=name):
|
|
self.assertEqual(response, expected["http"][name])
|
|
# Additive conversation/MCP routes have their own contract tests. Every
|
|
# pre-existing operation and request schema still matches the capture.
|
|
operations = actual["openapi_requests"]["operations"]
|
|
added = set(operations) - set(expected["openapi_requests"]["operations"])
|
|
self.assertEqual(
|
|
added,
|
|
{
|
|
"POST /api/conversations",
|
|
"DELETE /api/conversations/current",
|
|
"POST /api/conversations/current/interrupt",
|
|
"GET /api/mcp/tools",
|
|
},
|
|
)
|
|
for key in added:
|
|
del operations[key]
|
|
self.assertEqual(actual["openapi_requests"], expected["openapi_requests"])
|
|
self.assertEqual(actual["realtime"], expected["realtime"])
|
|
self.assertEqual(set(actual["http"]), set(expected["http"]))
|