refactor: rename API and frontend projects to Vocero
Rename the project directories, Python distribution and import package, and npm package. Update launcher, Docker, tooling, tests, lockfiles, and documentation while preserving existing API contracts. Make AGENTS.md self-contained and remove external skill and workflow references.
This commit is contained in:
+1342
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,573 @@
|
||||
"""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.providers import apipilot
|
||||
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": "",
|
||||
"S2S_APIPILOT_TOOLS_URL": "",
|
||||
"S2S_APIPILOT_TOOLS_EXECUTE_URL": "",
|
||||
"S2S_APIPILOT_SERVICE_TOKEN": "",
|
||||
"S2S_APIPILOT_API_KEY": "",
|
||||
"OPENAI_API_KEY": "",
|
||||
"S2S_REPORTS_QUICK_HINT_MAX": 5,
|
||||
"S2S_REPORTS_INJECT_MODE": "minimal",
|
||||
}
|
||||
TOOL = {
|
||||
"name": "fixture_tool",
|
||||
"description": "A fixture tool.",
|
||||
"parameters": {"type": "object", "properties": {"count": {"type": "integer"}}, "required": ["count"]},
|
||||
"requires_auth": False,
|
||||
"future_definition_field": {"nested": True},
|
||||
}
|
||||
|
||||
|
||||
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-legacy-unbound-token",
|
||||
"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,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
for method, path, kwargs in (
|
||||
("GET", "/api/apipilot/tools", {}),
|
||||
("POST", "/api/apipilot/execute", {"json": {"name": "fixture_tool"}}),
|
||||
):
|
||||
yield f"apipilot-disabled-{method}", method, path, kwargs, {}, []
|
||||
tool_config = {"S2S_APIPILOT_TOOLS_URL": "http://tools.test/apipilot"}
|
||||
yield "tools-query-validation", "GET", "/api/apipilot/tools?refresh=invalid", {}, tool_config, []
|
||||
for name, payload in (
|
||||
("list", [TOOL]),
|
||||
("result", {"result": [TOOL]}),
|
||||
("envelope", {"data": {"tools": [TOOL]}, "future": True}),
|
||||
("opaque", "anything"),
|
||||
):
|
||||
yield (
|
||||
f"tools-{name}",
|
||||
"GET",
|
||||
"/api/apipilot/tools?include_unauthorized=false&refresh=true",
|
||||
{"headers": {"Authorization": "Bearer fixture-token", "x-demo-session": "fixture-session"}},
|
||||
tool_config,
|
||||
[(200, payload)],
|
||||
)
|
||||
yield "tools-upstream-error", "GET", "/api/apipilot/tools", {}, tool_config, [(403, {"error": "fixture"})]
|
||||
yield (
|
||||
"execute-discovery-error-envelope",
|
||||
"POST",
|
||||
"/api/apipilot/execute",
|
||||
{"json": {"name": "fixture_tool"}},
|
||||
tool_config,
|
||||
[(503, {})],
|
||||
)
|
||||
yield (
|
||||
"execute-unknown-tool",
|
||||
"POST",
|
||||
"/api/apipilot/execute",
|
||||
{"json": {"name": "unknown"}},
|
||||
tool_config,
|
||||
[(200, [TOOL]), (200, [TOOL])],
|
||||
)
|
||||
yield (
|
||||
"execute-missing-argument",
|
||||
"POST",
|
||||
"/api/apipilot/execute",
|
||||
{"json": {"name": "fixture_tool"}},
|
||||
tool_config,
|
||||
[(200, [TOOL])],
|
||||
)
|
||||
yield (
|
||||
"execute-api-key-precheck",
|
||||
"POST",
|
||||
"/api/apipilot/execute",
|
||||
{"json": {"name": "fixture_tool", "arguments": {"count": 1}}},
|
||||
{
|
||||
**tool_config,
|
||||
"S2S_APIPILOT_API_KEY": "fixture-key",
|
||||
},
|
||||
[(200, [{**TOOL, "requires_auth": True}])],
|
||||
)
|
||||
for name, payload in (
|
||||
("envelope", {"success": True, "data": {"future": [1, None]}, "error": None, "future_envelope": True}),
|
||||
("raw-list", [{"future": True}]),
|
||||
("minimal-envelope", {"success": True}),
|
||||
(
|
||||
"error",
|
||||
{
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": {"status_code": 409, "code": "FIXTURE", "message": "Fixture", "future": True},
|
||||
},
|
||||
),
|
||||
):
|
||||
yield (
|
||||
f"execute-{name}",
|
||||
"POST",
|
||||
"/api/apipilot/execute",
|
||||
{
|
||||
"json": {
|
||||
"name": "fixture_tool",
|
||||
"arguments": {"count": "wrong-type-is-accepted", "unknown": 1},
|
||||
"future": True,
|
||||
}
|
||||
},
|
||||
tool_config,
|
||||
[(200, [TOOL]), (200, payload)],
|
||||
)
|
||||
|
||||
|
||||
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(apipilot, "_APIPILOT_CACHE", {}),
|
||||
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",
|
||||
"ApipilotExecuteRequest",
|
||||
"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])
|
||||
self.assertEqual(actual["openapi_requests"], expected["openapi_requests"])
|
||||
self.assertEqual(actual["realtime"], expected["realtime"])
|
||||
self.assertEqual(set(actual["http"]), set(expected["http"]))
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Validate owned fields without tightening or rewriting upstream payloads."""
|
||||
|
||||
import pytest
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from vocero_api.api.responses import validated_json_response
|
||||
from vocero_api.models.requests import ApipilotExecuteRequest, QueueEndPayload, SearchRequest
|
||||
from vocero_api.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_pass2": [1, None], "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 vocero_api.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
|
||||
config = spec["components"]["schemas"]["ConfigResponse"]
|
||||
assert config["properties"]["reasoningType"]["enum"] == ["PROXY", "OPENAI"]
|
||||
|
||||
|
||||
def test_legacy_entry_point_exports_the_renamed_application():
|
||||
import server
|
||||
from vocero_api.main import app
|
||||
|
||||
assert server.app is app
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("OPENAI", "OPENAI"),
|
||||
(" 'openai' ", "OPENAI"),
|
||||
('"OPENAI"', "OPENAI"),
|
||||
("PROXY", "PROXY"),
|
||||
("", "PROXY"),
|
||||
("UNSUPPORTED", "PROXY"),
|
||||
],
|
||||
)
|
||||
def test_reasoning_configuration_preserves_normalization_and_native_fallback(value, expected):
|
||||
from vocero_api.core.config import _normalize_reasoning_type
|
||||
|
||||
assert _normalize_reasoning_type(value) == expected
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Backend contract checks with local HTTP fixtures and a loopback voice server.
|
||||
|
||||
Run from vocero-api: uv run --locked pytest tests
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import ANY, AsyncMock, Mock, patch
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
from fastapi.testclient import TestClient
|
||||
from websockets.sync.server import serve
|
||||
|
||||
# Never consume deployment credentials or contact services from a developer's .env.
|
||||
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.providers import apipilot, caller
|
||||
from vocero_api.services import tool_context
|
||||
|
||||
|
||||
GRANT = {
|
||||
"session_id": "local-session",
|
||||
"connect_url": "ws://voice.test/v1/realtime?token=fixture",
|
||||
"pending_timeout_s": 60,
|
||||
}
|
||||
TICKET = {"state": "queued", "queue_id": "local-ticket", "position": 2, "poll_interval_s": 1}
|
||||
ASYNC_CLIENT = httpx.AsyncClient
|
||||
|
||||
|
||||
class BackendContractTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.upstream = Mock(side_effect=AssertionError("Unexpected upstream HTTP request"))
|
||||
self.enterContext(patch.object(settings, "LOAD_BALANCER_URL", "http://lb.test"))
|
||||
self.enterContext(
|
||||
patch.object(
|
||||
httpx,
|
||||
"AsyncClient",
|
||||
side_effect=lambda **kwargs: ASYNC_CLIENT(
|
||||
transport=httpx.MockTransport(self.upstream),
|
||||
**kwargs,
|
||||
),
|
||||
)
|
||||
)
|
||||
self.client = self.enterContext(TestClient(server.app))
|
||||
|
||||
def respond(self, *responses):
|
||||
self.upstream.side_effect = iter(responses)
|
||||
|
||||
def test_bootstrap_and_removed_account_contract(self):
|
||||
response = self.client.get("/api/config")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json()["lb"])
|
||||
self.assertFalse(response.json()["allowDirect"])
|
||||
self.assertNotIn("auth", response.json())
|
||||
paths = self.client.get("/openapi.json").json()["paths"]
|
||||
for method, path in (
|
||||
("GET", "/api/me"),
|
||||
("POST", "/api/session/heartbeat"),
|
||||
("POST", "/api/session/end"),
|
||||
):
|
||||
self.assertNotIn(path, paths)
|
||||
self.assertEqual(self.client.request(method, path).status_code, 404)
|
||||
self.assertFalse(any(getattr(route, "path", "").startswith("/oauth") for route in server.app.routes))
|
||||
self.upstream.assert_not_called()
|
||||
|
||||
def test_only_documentation_is_served_as_static_content(self):
|
||||
for path in ("/", "/index.html", "/main.js", "/style.css", "/server.py", "/requirements.txt"):
|
||||
self.assertEqual(self.client.get(path).status_code, 404)
|
||||
self.assertEqual(self.client.get("/docs").status_code, 200)
|
||||
self.assertEqual(self.client.get("/docs/README.md").status_code, 200)
|
||||
for path in ("/project/", "/public/docs.html"):
|
||||
response = self.client.get(path, follow_redirects=False)
|
||||
self.assertEqual(response.status_code, 307)
|
||||
self.assertEqual(response.headers["location"], "/docs/proposals/vocero-architecture.html")
|
||||
self.upstream.assert_not_called()
|
||||
|
||||
def test_session_grant_passes_through_without_extra_fields_or_cookies(self):
|
||||
self.respond(httpx.Response(200, json=GRANT))
|
||||
response = self.client.post("/api/session", json={})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), GRANT)
|
||||
self.assertNotIn("set-cookie", response.headers)
|
||||
request = self.upstream.call_args.args[0]
|
||||
self.assertEqual((request.method, str(request.url)), ("POST", "http://lb.test/session"))
|
||||
self.assertEqual(json.loads(request.content), {})
|
||||
|
||||
def test_queue_polling_returns_positions_and_then_grant(self):
|
||||
self.respond(
|
||||
httpx.Response(200, json=TICKET),
|
||||
httpx.Response(200, json={**TICKET, "position": 1}),
|
||||
httpx.Response(200, json=GRANT),
|
||||
)
|
||||
self.assertEqual(self.client.post("/api/session").json(), TICKET)
|
||||
self.assertEqual(self.client.get("/api/queue/local-ticket").json(), {**TICKET, "position": 1})
|
||||
response = self.client.get("/api/queue/local-ticket")
|
||||
self.assertEqual(response.json(), GRANT)
|
||||
self.assertNotIn("set-cookie", response.headers)
|
||||
self.assertEqual(
|
||||
[(call.args[0].method, call.args[0].url.path) for call in self.upstream.call_args_list],
|
||||
[("POST", "/session"), ("GET", "/queue/local-ticket"), ("GET", "/queue/local-ticket")],
|
||||
)
|
||||
|
||||
def test_capacity_and_expired_queue_responses_remain(self):
|
||||
self.respond(httpx.Response(503, json={"state": "at_capacity"}), httpx.Response(404))
|
||||
response = self.client.post("/api/session")
|
||||
self.assertEqual((response.status_code, response.json()), (503, {"state": "at_capacity"}))
|
||||
response = self.client.get("/api/queue/local-ticket")
|
||||
self.assertEqual((response.status_code, response.json()), (404, {"state": "expired"}))
|
||||
|
||||
def test_explicit_and_beacon_queue_cancellation(self):
|
||||
self.respond(httpx.Response(204), httpx.Response(204))
|
||||
self.assertEqual(self.client.delete("/api/queue/local-ticket").json(), {"ok": True})
|
||||
# sendBeacon posts a JSON blob, not a form or a session identifier.
|
||||
response = self.client.post(
|
||||
"/api/queue/end",
|
||||
content=json.dumps({"queueId": "local-ticket"}),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(response.json(), {"ok": True})
|
||||
for call in self.upstream.call_args_list:
|
||||
self.assertEqual((call.args[0].method, call.args[0].url.path), ("DELETE", "/queue/local-ticket"))
|
||||
self.assertEqual(self.upstream.call_count, 2)
|
||||
|
||||
def test_empty_beacon_is_harmless_and_cancellation_is_best_effort(self):
|
||||
self.assertEqual(self.client.post("/api/queue/end", content="").json(), {"ok": True})
|
||||
self.upstream.assert_not_called()
|
||||
self.upstream.side_effect = httpx.ConnectError("Local fixture unavailable")
|
||||
self.assertEqual(self.client.delete("/api/queue/local-ticket").json(), {"ok": True})
|
||||
|
||||
def test_upstream_failures_are_still_reported(self):
|
||||
self.upstream.side_effect = httpx.ConnectError("Local fixture unavailable")
|
||||
self.assertEqual(self.client.post("/api/session").status_code, 502)
|
||||
self.assertEqual(self.client.get("/api/queue/local-ticket").status_code, 502)
|
||||
self.respond(httpx.Response(429), httpx.Response(500))
|
||||
self.assertEqual(self.client.post("/api/session").status_code, 502)
|
||||
self.assertEqual(self.client.get("/api/queue/local-ticket").status_code, 502)
|
||||
|
||||
def test_direct_mode_disables_lb_and_preserves_pinned_voice_url(self):
|
||||
with (
|
||||
patch.object(settings, "LOAD_BALANCER_URL", ""),
|
||||
patch.object(
|
||||
settings,
|
||||
"SPEECH_TO_SPEECH_URL",
|
||||
GRANT["connect_url"],
|
||||
),
|
||||
):
|
||||
config = self.client.get("/api/config").json()
|
||||
self.assertEqual(config["s2sUrl"], GRANT["connect_url"])
|
||||
self.assertFalse(config["lb"])
|
||||
self.assertTrue(config["allowDirect"])
|
||||
for method, path in (
|
||||
("POST", "/api/session"),
|
||||
("GET", "/api/queue/local-ticket"),
|
||||
("DELETE", "/api/queue/local-ticket"),
|
||||
("POST", "/api/queue/end"),
|
||||
):
|
||||
self.assertEqual(self.client.request(method, path).status_code, 404)
|
||||
self.upstream.assert_not_called()
|
||||
|
||||
def test_caller_profile_still_uses_upstream_login_token(self):
|
||||
profile = {"result": {"name": "Local Caller"}}
|
||||
self.respond(
|
||||
httpx.Response(200, json={"token": "fixture-token"}),
|
||||
httpx.Response(200, json=profile),
|
||||
)
|
||||
with (
|
||||
patch.object(settings, "S2S_LOGIN_BY_PHONE", "http://profile.test/login"),
|
||||
patch.object(
|
||||
settings,
|
||||
"VOCERO_USER_PHONE_AUTH_URL",
|
||||
"http://profile.test/user",
|
||||
),
|
||||
):
|
||||
response = self.client.get("/api/user/phone", params={"phoneNumber": "+56000000000"})
|
||||
self.assertEqual(response.json(), profile)
|
||||
login, lookup = [call.args[0] for call in self.upstream.call_args_list]
|
||||
self.assertEqual(json.loads(login.content), {"phoneNumber": "+56000000000"})
|
||||
self.assertEqual(lookup.headers["authorization"], "Bearer fixture-token")
|
||||
self.assertEqual(lookup.url.params["phoneNumber"], "+56000000000")
|
||||
|
||||
def test_caller_auth_headers_remain_independent(self):
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [
|
||||
(b"authorization", b"Bearer fixture-token"),
|
||||
(b"x-demo-session", b"fixture-demo"),
|
||||
],
|
||||
}
|
||||
)
|
||||
self.assertEqual(apipilot._apipilot_parse_authorization(request), ("Bearer fixture-token", "fixture-demo"))
|
||||
|
||||
|
||||
class VoiceProxyTests(unittest.TestCase):
|
||||
def test_apipilot_catalog_and_caller_defaults_reach_the_voice_session(self):
|
||||
closed = threading.Event()
|
||||
catalog = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"name": "get_caller_briefing",
|
||||
"description": "Retrieve the caller's local test briefing.",
|
||||
"requires_auth": True,
|
||||
"parameters": {"required": ["phone", "episode_id", "email"]},
|
||||
}
|
||||
]
|
||||
)
|
||||
login = AsyncMock(return_value=("fixture-token", {}))
|
||||
profile = AsyncMock(return_value={"user": {"email": "caller@example.test"}})
|
||||
|
||||
def voice(socket):
|
||||
try:
|
||||
socket.send(json.dumps({"type": "session.created"}))
|
||||
for frame in socket:
|
||||
socket.send(frame)
|
||||
finally:
|
||||
closed.set()
|
||||
|
||||
with serve(voice, "127.0.0.1", 0) as upstream:
|
||||
thread = threading.Thread(target=upstream.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(thread.join, 5)
|
||||
target = f"ws://127.0.0.1:{upstream.socket.getsockname()[1]}/v1/realtime"
|
||||
with (
|
||||
patch.object(settings, "S2S_PROXY_TARGET", target),
|
||||
patch.object(settings, "S2S_INJECT_PROFILE", False),
|
||||
patch.object(settings, "S2S_APIPILOT_TOOLS_URL", "http://catalog.test/tools"),
|
||||
patch.object(settings, "S2S_LOGIN_BY_PHONE", "http://profile.test/login"),
|
||||
patch.object(settings, "VOCERO_USER_PHONE_AUTH_URL", "http://profile.test/user"),
|
||||
patch.object(apipilot, "_apipilot_fetch_tools", catalog),
|
||||
patch.object(caller, "_login_by_phone", login),
|
||||
patch.object(caller, "_fetch_user_profile_for_phone", profile),
|
||||
patch.object(httpx, "AsyncClient", side_effect=AssertionError("Unexpected HTTP request")),
|
||||
TestClient(server.app) as client,
|
||||
):
|
||||
with client.websocket_connect(
|
||||
"/v1/realtime?phoneNumber=%2B56000000000&episode_id=fixture-episode",
|
||||
) as socket:
|
||||
self.assertEqual(socket.receive_json(), {"type": "session.created"})
|
||||
socket.send_json({"type": "session.update", "session": {"instructions": "Local fixture"}})
|
||||
update = socket.receive_json()
|
||||
self.assertEqual(update["type"], "session.update")
|
||||
instructions = update["session"]["instructions"]
|
||||
for expected in (
|
||||
"Local fixture",
|
||||
tool_context.APIPILOT_PASS1_LIST_MARKER,
|
||||
"name: get_caller_briefing",
|
||||
'phone = "+56000000000"',
|
||||
'episode_id = "fixture-episode"',
|
||||
'email = "caller@example.test"',
|
||||
):
|
||||
self.assertIn(expected, instructions)
|
||||
# Configuration injection must preserve ordinary audio forwarding.
|
||||
audio = {"type": "input_audio_buffer.append", "audio": "AAA="}
|
||||
socket.send_json(audio)
|
||||
self.assertEqual(socket.receive_json(), audio)
|
||||
self.assertTrue(closed.wait(3), "Client disconnect must close the upstream WebSocket")
|
||||
login.assert_awaited_once_with("+56000000000")
|
||||
profile.assert_awaited_once_with("+56000000000", auth_token="fixture-token")
|
||||
catalog.assert_awaited_once_with(auth_header="Bearer fixture-token", demo_session=ANY, refresh=False)
|
||||
|
||||
def test_proxy_voice_frames_keepalive_and_client_disconnect(self):
|
||||
closed = threading.Event()
|
||||
received = []
|
||||
keepalive = threading.Event()
|
||||
|
||||
def voice(socket):
|
||||
try:
|
||||
socket.send(json.dumps({"type": "session.created"}))
|
||||
if socket.ping().wait(3):
|
||||
keepalive.set()
|
||||
for frame in socket:
|
||||
received.append(frame)
|
||||
socket.send(frame)
|
||||
finally:
|
||||
closed.set()
|
||||
|
||||
with serve(voice, "127.0.0.1", 0) as upstream:
|
||||
thread = threading.Thread(target=upstream.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(thread.join, 5)
|
||||
target = f"ws://127.0.0.1:{upstream.socket.getsockname()[1]}/v1/realtime"
|
||||
with patch.object(settings, "S2S_PROXY_TARGET", target), TestClient(server.app) as client:
|
||||
config = client.get("/api/config", headers={"host": "localhost:19420"}).json()
|
||||
self.assertEqual(config["s2sUrl"], "ws://localhost:19420/v1/realtime")
|
||||
with client.websocket_connect("/v1/realtime") as socket:
|
||||
self.assertEqual(socket.receive_json(), {"type": "session.created"})
|
||||
update = {"type": "session.update", "session": {"instructions": "Local fixture"}}
|
||||
socket.send_json(update)
|
||||
self.assertEqual(socket.receive_json(), update)
|
||||
socket.send_bytes(b"\x00\x01\x02\x03")
|
||||
self.assertEqual(socket.receive_bytes(), b"\x00\x01\x02\x03")
|
||||
self.assertTrue(keepalive.wait(3))
|
||||
self.assertTrue(closed.wait(3), "Client disconnect must close the upstream WebSocket")
|
||||
self.assertEqual(len(received), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user