refactor(reasoning)!: remove Node-RED engine and integrations
BREAKING CHANGE: NODE_RED reasoning is no longer supported. Unsupported mode values fall back to PROXY. PROXY and OPENAI remain available.
This commit is contained in:
-80
@@ -744,14 +744,6 @@
|
||||
},
|
||||
"upstream": []
|
||||
},
|
||||
"reasoning-NODE_RED-legacy-unbound-token": {
|
||||
"status": 500,
|
||||
"body": "Internal Server Error",
|
||||
"headers": {
|
||||
"content-type": "text/plain; charset=utf-8"
|
||||
},
|
||||
"upstream": []
|
||||
},
|
||||
"reasoning-legacy-unbound-profile": {
|
||||
"status": 500,
|
||||
"body": "Internal Server Error",
|
||||
@@ -760,78 +752,6 @@
|
||||
},
|
||||
"upstream": []
|
||||
},
|
||||
"reasoning-node-answer": {
|
||||
"status": 200,
|
||||
"body": "{\"answer\":\"answer\",\"usage\":{\"future\":true},\"latency_ms\":\"loose\",\"intent\":{\"future\":1}}",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"upstream": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://profile.test/user?phoneNumber=123",
|
||||
"body": "",
|
||||
"headers": {}
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "http://reasoning.test/api/reasoning",
|
||||
"body": "{\"transcript\":\"hello\",\"instructions\":\"Fixture instructions \\nPRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\\n1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. No words, phrases or confirmations in another language.\\n2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\\n3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\\n4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. Use a couple of short sentences, then ask whether the user wants more details. Fixture current date: 2026-01-04 \\n Caller profile resolved from the page phoneNumber: {\\\"name\\\": \\\"Caller\\\"}. Use this as trusted caller context when relevant. IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') or a name from any other language, respond ONLY in SPANISH (Castilian). Foreign names do NOT authorize switching languages. Never write phrases, words, questions or confirmations in Italian, English or another language. Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. PROPER NAME: never adapt, translate or normalize proper names (for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name.\",\"language\":\"es\",\"user_context\":\"PRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\\n1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. No words, phrases or confirmations in another language.\\n2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\\n3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\\n4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. Use a couple of short sentences, then ask whether the user wants more details.\\n\\nFixture current date: 2026-01-04\\n\\nCaller profile resolved from the page phoneNumber: {\\\"name\\\": \\\"Caller\\\"}. Use this as trusted caller context when relevant. IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') or a name from any other language, respond ONLY in SPANISH (Castilian). Foreign names do NOT authorize switching languages. Never write phrases, words, questions or confirmations in Italian, English or another language. Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. PROPER NAME: never adapt, translate or normalize proper names (for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name.\"}",
|
||||
"headers": {
|
||||
"content-type": "application/json",
|
||||
"x-s2s-caller": "demo-server"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"reasoning-node-log-only": {
|
||||
"status": 200,
|
||||
"body": "{\"answer\":\"\",\"log_only\":true,\"debug\":{\"future\":true}}",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"upstream": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://profile.test/user?phoneNumber=123",
|
||||
"body": "",
|
||||
"headers": {}
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "http://reasoning.test/api/reasoning",
|
||||
"body": "{\"transcript\":\"hello\",\"instructions\":\"Fixture instructions \\nPRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\\n1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. No words, phrases or confirmations in another language.\\n2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\\n3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\\n4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. Use a couple of short sentences, then ask whether the user wants more details. Fixture current date: 2026-01-04 \\n Caller profile resolved from the page phoneNumber: {\\\"name\\\": \\\"Caller\\\"}. Use this as trusted caller context when relevant. IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') or a name from any other language, respond ONLY in SPANISH (Castilian). Foreign names do NOT authorize switching languages. Never write phrases, words, questions or confirmations in Italian, English or another language. Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. PROPER NAME: never adapt, translate or normalize proper names (for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name.\",\"language\":\"es\",\"user_context\":\"PRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\\n1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. No words, phrases or confirmations in another language.\\n2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\\n3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\\n4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. Use a couple of short sentences, then ask whether the user wants more details.\\n\\nFixture current date: 2026-01-04\\n\\nCaller profile resolved from the page phoneNumber: {\\\"name\\\": \\\"Caller\\\"}. Use this as trusted caller context when relevant. IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') or a name from any other language, respond ONLY in SPANISH (Castilian). Foreign names do NOT authorize switching languages. Never write phrases, words, questions or confirmations in Italian, English or another language. Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. PROPER NAME: never adapt, translate or normalize proper names (for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name.\"}",
|
||||
"headers": {
|
||||
"content-type": "application/json",
|
||||
"x-s2s-caller": "demo-server"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"reasoning-node-invalid-answer": {
|
||||
"status": 502,
|
||||
"body": "{\"detail\":\"Node-RED devolvió un campo answer no válido.\"}",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"upstream": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://profile.test/user?phoneNumber=123",
|
||||
"body": "",
|
||||
"headers": {}
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "http://reasoning.test/api/reasoning",
|
||||
"body": "{\"transcript\":\"hello\",\"instructions\":\"Fixture instructions \\nPRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\\n1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. No words, phrases or confirmations in another language.\\n2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\\n3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\\n4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. Use a couple of short sentences, then ask whether the user wants more details. Fixture current date: 2026-01-04 \\n Caller profile resolved from the page phoneNumber: {\\\"name\\\": \\\"Caller\\\"}. Use this as trusted caller context when relevant. IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') or a name from any other language, respond ONLY in SPANISH (Castilian). Foreign names do NOT authorize switching languages. Never write phrases, words, questions or confirmations in Italian, English or another language. Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. PROPER NAME: never adapt, translate or normalize proper names (for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name.\",\"language\":\"es\",\"user_context\":\"PRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\\n1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. No words, phrases or confirmations in another language.\\n2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\\n3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\\n4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. Use a couple of short sentences, then ask whether the user wants more details.\\n\\nFixture current date: 2026-01-04\\n\\nCaller profile resolved from the page phoneNumber: {\\\"name\\\": \\\"Caller\\\"}. Use this as trusted caller context when relevant. IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') or a name from any other language, respond ONLY in SPANISH (Castilian). Foreign names do NOT authorize switching languages. Never write phrases, words, questions or confirmations in Italian, English or another language. Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. PROPER NAME: never adapt, translate or normalize proper names (for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name.\"}",
|
||||
"headers": {
|
||||
"content-type": "application/json",
|
||||
"x-s2s-caller": "demo-server"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"reasoning-openai-answer": {
|
||||
"status": 200,
|
||||
"body": "{\"answer\":\"answer\"}",
|
||||
|
||||
@@ -11,7 +11,7 @@ import json
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -21,7 +21,7 @@ with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_
|
||||
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, reasoning
|
||||
from s2s_client.services import context
|
||||
|
||||
ASYNC_CLIENT = httpx.AsyncClient
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "legacy-contracts.json"
|
||||
@@ -47,7 +47,6 @@ BASE_CONFIG = {
|
||||
"S2S_APIPILOT_SERVICE_TOKEN": "",
|
||||
"S2S_APIPILOT_API_KEY": "",
|
||||
"OPENAI_API_KEY": "",
|
||||
"NODE_RED_REASONING_URL": "",
|
||||
"S2S_REPORTS_QUICK_HINT_MAX": 5,
|
||||
"S2S_REPORTS_INJECT_MODE": "minimal",
|
||||
}
|
||||
@@ -268,15 +267,14 @@ def cases():
|
||||
|
||||
for name, payload in (("missing", {}), ("proxy", {"transcript": "hello"}), ("proxy-blank", {"transcript": " "})):
|
||||
yield f"reasoning-{name}", "POST", "/api/reasoning", {"json": payload}, {}, []
|
||||
for mode in ("OPENAI", "NODE_RED"):
|
||||
yield (
|
||||
f"reasoning-{mode}-legacy-unbound-token",
|
||||
"POST",
|
||||
"/api/reasoning",
|
||||
{"json": {"transcript": "hello"}},
|
||||
{"S2S_REASONING_TYPE": mode},
|
||||
[],
|
||||
)
|
||||
yield (
|
||||
"reasoning-OPENAI-legacy-unbound-token",
|
||||
"POST",
|
||||
"/api/reasoning",
|
||||
{"json": {"transcript": "hello"}},
|
||||
{"S2S_REASONING_TYPE": "OPENAI"},
|
||||
[],
|
||||
)
|
||||
yield (
|
||||
"reasoning-legacy-unbound-profile",
|
||||
"POST",
|
||||
@@ -292,8 +290,7 @@ def cases():
|
||||
reasoning_config = {
|
||||
**profile_config,
|
||||
"S2S_INJECT_PROFILE": True,
|
||||
"S2S_REASONING_TYPE": "NODE_RED",
|
||||
"NODE_RED_REASONING_URL": "http://reasoning.test",
|
||||
"S2S_REASONING_TYPE": "OPENAI",
|
||||
}
|
||||
reasoning_request = {
|
||||
"json": {
|
||||
@@ -304,28 +301,6 @@ def cases():
|
||||
"future": True,
|
||||
}
|
||||
}
|
||||
for name, payload in (
|
||||
(
|
||||
"answer",
|
||||
{
|
||||
"answer": " answer ",
|
||||
"latency_ms": "loose",
|
||||
"intent": {"future": 1},
|
||||
"usage": {"future": True},
|
||||
"future": True,
|
||||
},
|
||||
),
|
||||
("log-only", {"answer": "", "log_only": True, "debug": {"future": True}}),
|
||||
("invalid-answer", {"answer": 5}),
|
||||
):
|
||||
yield (
|
||||
f"reasoning-node-{name}",
|
||||
"POST",
|
||||
"/api/reasoning",
|
||||
reasoning_request,
|
||||
reasoning_config,
|
||||
[(200, {"name": "Caller"}), (200, payload)],
|
||||
)
|
||||
yield (
|
||||
"reasoning-openai-answer",
|
||||
"POST",
|
||||
@@ -333,7 +308,6 @@ def cases():
|
||||
reasoning_request,
|
||||
{
|
||||
**reasoning_config,
|
||||
"S2S_REASONING_TYPE": "OPENAI",
|
||||
"OPENAI_API_KEY": "fixture-key",
|
||||
},
|
||||
[
|
||||
@@ -454,7 +428,6 @@ def capture_http():
|
||||
"x-demo-session",
|
||||
"x-api-key",
|
||||
"content-type",
|
||||
"x-s2s-caller",
|
||||
)
|
||||
if key in request.headers
|
||||
},
|
||||
@@ -534,8 +507,7 @@ async def capture_realtime():
|
||||
yield json.dumps({"type": "error", "error": {"message": "Unknown or invalid event: session.update"}})
|
||||
yield json.dumps({"type": "future.after.suppressed.error"})
|
||||
|
||||
with patch.object(reasoning, "_forward_agent_text_to_nodered", new_callable=AsyncMock):
|
||||
await proxy._upstream_to_client(Socket(), upstream(), "fixture", {})
|
||||
await proxy._upstream_to_client(Socket(), upstream(), "fixture", {})
|
||||
return frames
|
||||
|
||||
|
||||
@@ -548,6 +520,48 @@ def capture():
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -24,7 +24,7 @@ from s2s_client.models.responses import (
|
||||
(AllocationResponse, {"state": [1, None], "connectUrl": "wss://host", "future": {"x": 1.0}}),
|
||||
(ToolExecutionResponse, {"success": True, "unknown": {"nested": [None, 1.0, False]}}),
|
||||
(ToolExecutionResponse, {"success": False, "data": 42, "error": "loose error", "future": []}),
|
||||
(ReasoningResponse, {"answer": "á", "usage": [1, None], "debug": False, "future": True}),
|
||||
(ReasoningResponse, {"answer": "á", "usage_pass2": [1, None], "future": True}),
|
||||
(SearchResponse, {"query": "q", "answer": {"x": 2}, "results": [{"title": [], "snippet": 7, "url": None}]}),
|
||||
(
|
||||
ReportsResponse,
|
||||
@@ -69,3 +69,22 @@ def test_http_routes_publish_response_schemas():
|
||||
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"]
|
||||
|
||||
|
||||
@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 s2s_client.core.config import _normalize_reasoning_type
|
||||
|
||||
assert _normalize_reasoning_type(value) == expected
|
||||
|
||||
Reference in New Issue
Block a user