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:
2026-10-06 19:26:55 -03:00
parent e0304b2bdb
commit abd03ed07d
105 changed files with 353 additions and 312 deletions
File diff suppressed because it is too large Load Diff
+573
View File
@@ -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"]))
+97
View File
@@ -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
+302
View File
@@ -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()