BREAKING CHANGE: NODE_RED reasoning is no longer supported. Unsupported mode values fall back to PROXY. PROXY and OPENAI remain available.
574 lines
20 KiB
Python
574 lines
20 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 s2s_client import main as server
|
|
from s2s_client.core import config as settings
|
|
from s2s_client.providers import apipilot
|
|
from s2s_client.realtime import proxy
|
|
from s2s_client.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"]))
|