From 34f0d1b125ebac5a63cb112c0c75ea27d135b289 Mon Sep 17 00:00:00 2001 From: Alvaro Parker Date: Tue, 6 Oct 2026 18:10:34 -0300 Subject: [PATCH] test(api): capture legacy HTTP and realtime contracts --- .../tests/fixtures/legacy-contracts.json | 1422 +++++++++++++++++ s2s-client/tests/test_compatibility.py | 275 ++++ 2 files changed, 1697 insertions(+) create mode 100644 s2s-client/tests/fixtures/legacy-contracts.json create mode 100644 s2s-client/tests/test_compatibility.py diff --git a/s2s-client/tests/fixtures/legacy-contracts.json b/s2s-client/tests/fixtures/legacy-contracts.json new file mode 100644 index 0000000..4b49916 --- /dev/null +++ b/s2s-client/tests/fixtures/legacy-contracts.json @@ -0,0 +1,1422 @@ +{ + "http": { + "config-default": { + "status": 200, + "body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\",\"apipilot\":false}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "config-proxy": { + "status": 200, + "body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"wss://voice.test/v1/realtime\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\",\"apipilot\":false}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "config-direct": { + "status": 200, + "body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"wss://voice.test/realtime?token=fixture\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\",\"apipilot\":false}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "config-lb": { + "status": 200, + "body": "{\"search\":false,\"lb\":true,\"allowDirect\":false,\"s2sUrl\":\"\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\",\"apipilot\":false}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "removed-/api/me": { + "status": 404, + "body": "{\"detail\":\"Not Found\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "removed-/api/session/heartbeat": { + "status": 404, + "body": "{\"detail\":\"Not Found\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "removed-/api/session/end": { + "status": 404, + "body": "{\"detail\":\"Not Found\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "removed-/": { + "status": 404, + "body": "{\"detail\":\"Not Found\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "removed-/server.py": { + "status": 404, + "body": "{\"detail\":\"Not Found\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "redirect-/project": { + "status": 307, + "body": "", + "headers": { + "location": "/docs/proposals/vocero-architecture.html" + }, + "upstream": [] + }, + "redirect-/project/": { + "status": 307, + "body": "", + "headers": { + "location": "/docs/proposals/vocero-architecture.html" + }, + "upstream": [] + }, + "redirect-/project/index.html": { + "status": 307, + "body": "", + "headers": { + "location": "/docs/proposals/vocero-architecture.html" + }, + "upstream": [] + }, + "redirect-/public/docs.html": { + "status": 307, + "body": "", + "headers": { + "location": "/docs/proposals/vocero-architecture.html" + }, + "upstream": [] + }, + "disabled-/api/session": { + "status": 404, + "body": "{\"detail\":\"No encontrado.\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "disabled-/api/queue/end": { + "status": 404, + "body": "{\"detail\":\"No encontrado.\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "disabled-queue-GET": { + "status": 404, + "body": "{\"detail\":\"No encontrado.\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "disabled-queue-DELETE": { + "status": 404, + "body": "{\"detail\":\"No encontrado.\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "search-missing": { + "status": 422, + "body": "{\"detail\":[{\"type\":\"missing\",\"loc\":[\"body\",\"query\"],\"msg\":\"Field required\",\"input\":{}}]}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "search-blank": { + "status": 400, + "body": "{\"detail\":\"La consulta está vacía.\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "search-wrong-type": { + "status": 422, + "body": "{\"detail\":[{\"type\":\"string_type\",\"loc\":[\"body\",\"query\"],\"msg\":\"Input should be a valid string\",\"input\":17}]}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "search-null": { + "status": 422, + "body": "{\"detail\":[{\"type\":\"string_type\",\"loc\":[\"body\",\"query\"],\"msg\":\"Input should be a valid string\",\"input\":null}]}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "search-unconfigured": { + "status": 503, + "body": "{\"detail\":\"La búsqueda no está configurada.\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "search-success-extra-fields": { + "status": 200, + "body": "{\"query\":\"hello\",\"answer\":\"Answer\",\"results\":[{\"title\":\"Title\",\"snippet\":\"Text\",\"url\":\"https://example.test\"}]}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "POST", + "url": "https://google.serper.dev/search", + "body": "{\"q\":\"hello\",\"num\":5}", + "headers": { + "x-api-key": "caller-fixture-key", + "content-type": "application/json" + } + } + ] + }, + "search-loose-values": { + "status": 500, + "body": "Internal Server Error", + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "upstream": [ + { + "method": "POST", + "url": "https://google.serper.dev/search", + "body": "{\"q\":\"hello\",\"num\":5}", + "headers": { + "x-api-key": "server-fixture-key", + "content-type": "application/json" + } + } + ] + }, + "search-upstream-error": { + "status": 502, + "body": "{\"detail\":\"Error del proveedor de búsqueda (429): Fixture credits exhausted\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "POST", + "url": "https://google.serper.dev/search", + "body": "{\"q\":\"hello\",\"num\":5}", + "headers": { + "x-api-key": "server-fixture-key", + "content-type": "application/json" + } + } + ] + }, + "search-invalid-upstream-json": { + "status": 500, + "body": "Internal Server Error", + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "upstream": [ + { + "method": "POST", + "url": "https://google.serper.dev/search", + "body": "{\"q\":\"hello\",\"num\":5}", + "headers": { + "x-api-key": "server-fixture-key", + "content-type": "application/json" + } + } + ] + }, + "profile-missing-phone": { + "status": 422, + "body": "{\"detail\":[{\"type\":\"missing\",\"loc\":[\"query\",\"phoneNumber\"],\"msg\":\"Field required\",\"input\":null}]}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "profile-unconfigured": { + "status": 503, + "body": "{\"detail\":\"VOCERO_USER_PHONE_AUTH_URL no está configurado en este despliegue.\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "profile-blank-phone": { + "status": 400, + "body": "{\"detail\":\"Se requiere el campo phoneNumber.\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "profile-object": { + "status": 200, + "body": "{\"result\":{\"name\":\"Caller\",\"unknown\":{\"nested\":[1,null]}}}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://profile.test/user?phoneNumber=%2B56000000000", + "body": "", + "headers": {} + } + ] + }, + "profile-array": { + "status": 200, + "body": "[1,{\"future\":\"field\"}]", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://profile.test/user?phoneNumber=%2B56000000000", + "body": "", + "headers": {} + } + ] + }, + "profile-null": { + "status": 200, + "body": "null", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://profile.test/user?phoneNumber=%2B56000000000", + "body": "", + "headers": {} + } + ] + }, + "profile-string": { + "status": 200, + "body": "\"opaque profile\"", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://profile.test/user?phoneNumber=%2B56000000000", + "body": "", + "headers": {} + } + ] + }, + "profile-error": { + "status": 403, + "body": "{\"future_error\":true}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://profile.test/user?phoneNumber=%2B56000000000", + "body": "", + "headers": {} + } + ] + }, + "profile-invalid-json": { + "status": 502, + "body": "{\"detail\":\"El servicio de información del usuario devolvió un JSON no válido.\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://profile.test/user?phoneNumber=%2B56000000000", + "body": "", + "headers": {} + } + ] + }, + "profile-login-token": { + "status": 200, + "body": "{\"name\":\"Caller\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "POST", + "url": "http://profile.test/login", + "body": "{\"phoneNumber\":\"123\"}", + "headers": { + "content-type": "application/json" + } + }, + { + "method": "GET", + "url": "http://profile.test/user?phoneNumber=123", + "body": "", + "headers": { + "authorization": "Bearer fixture-token" + } + } + ] + }, + "reports-disabled": { + "status": 200, + "body": "{\"count\":0,\"wrappedLen\":0,\"hints\":[],\"entries\":[],\"skipped\":true,\"reason\":\"S2S_INJECT_REPORTS está deshabilitado en este despliegue; no se muestran reportes programados en la interfaz ni se envían al modelo.\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "reports-unconfigured": { + "status": 503, + "body": "{\"detail\":\"S2S_REPORT_LIST no está configurado en este despliegue.\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "reports-empty": { + "status": 200, + "body": "{\"count\":0,\"wrappedLen\":0,\"hints\":[],\"entries\":[],\"skipped\":true,\"reason\":\"Lista vacía, error del servicio de origen o ningún contenido disponible.\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://reports.test/list", + "body": "", + "headers": {} + } + ] + }, + "reports-upstream-failure": { + "status": 200, + "body": "{\"count\":0,\"wrappedLen\":0,\"hints\":[],\"entries\":[],\"skipped\":true,\"reason\":\"Lista vacía, error del servicio de origen o ningún contenido disponible.\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://reports.test/list", + "body": "", + "headers": {} + } + ] + }, + "reports-normalization": { + "status": 200, + "body": "{\"count\":2,\"rawCount\":3,\"cap\":5,\"wrappedLen\":271,\"hints\":[\"• Reporte #1: 'Weekly' — programado para 2026-01-03 12:00\",\"• Reporte #2: 'Daily' — programado para 2026-01-02 12:00\"],\"entries\":[{\"sourceScheduleLabel\":\"Weekly\",\"date\":\"2026-01-03T12:00:00Z\"},{\"sourceScheduleLabel\":\"Daily\",\"date\":\"2026-01-02T12:00:00Z\"}]}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://reports.test/list?phoneNumber=123", + "body": "", + "headers": {} + } + ] + }, + "session-grant": { + "status": 200, + "body": "{\"session_id\":\"session\",\"connect_url\":\"ws://voice.test?token=fixture\",\"future\":{\"nested\":true}}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "POST", + "url": "http://lb.test/session", + "body": "{}", + "headers": { + "content-type": "application/json" + } + } + ] + }, + "session-camelcase": { + "status": 200, + "body": "{\"sessionId\":\"session\",\"connectUrl\":\"ws://voice.test\",\"unknown\":null}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "POST", + "url": "http://lb.test/session", + "body": "{}", + "headers": { + "content-type": "application/json" + } + } + ] + }, + "session-queued": { + "status": 200, + "body": "{\"state\":\"queued\",\"queue_id\":\"ticket\",\"position\":3,\"future\":[1]}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "POST", + "url": "http://lb.test/session", + "body": "{}", + "headers": { + "content-type": "application/json" + } + } + ] + }, + "session-empty": { + "status": 200, + "body": "{}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "POST", + "url": "http://lb.test/session", + "body": "{}", + "headers": { + "content-type": "application/json" + } + } + ] + }, + "session-capacity": { + "status": 503, + "body": "{\"state\":\"at_capacity\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "POST", + "url": "http://lb.test/session", + "body": "{}", + "headers": { + "content-type": "application/json" + } + } + ] + }, + "session-other-error": { + "status": 502, + "body": "{\"detail\":\"No se pudo establecer la sesión (429).\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "POST", + "url": "http://lb.test/session", + "body": "{}", + "headers": { + "content-type": "application/json" + } + } + ] + }, + "session-invalid-json": { + "status": 500, + "body": "Internal Server Error", + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "upstream": [ + { + "method": "POST", + "url": "http://lb.test/session", + "body": "{}", + "headers": { + "content-type": "application/json" + } + } + ] + }, + "queue-waiting": { + "status": 200, + "body": "{\"state\":\"queued\",\"queue_id\":\"ticket\",\"position\":null,\"future\":true}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://lb.test/queue/ticket", + "body": "", + "headers": {} + } + ] + }, + "queue-grant": { + "status": 200, + "body": "{\"session_id\":\"session\",\"connect_url\":\"ws://voice.test\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://lb.test/queue/ticket", + "body": "", + "headers": {} + } + ] + }, + "queue-expired": { + "status": 404, + "body": "{\"state\":\"expired\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://lb.test/queue/ticket", + "body": "", + "headers": {} + } + ] + }, + "queue-error": { + "status": 502, + "body": "{\"detail\":\"No se pudo consultar la cola de espera (500).\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://lb.test/queue/ticket", + "body": "", + "headers": {} + } + ] + }, + "queue-invalid-json": { + "status": 500, + "body": "Internal Server Error", + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "upstream": [ + { + "method": "GET", + "url": "http://lb.test/queue/ticket", + "body": "", + "headers": {} + } + ] + }, + "queue-delete": { + "status": 200, + "body": "{\"ok\":true}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "DELETE", + "url": "http://lb.test/queue/ticket", + "body": "", + "headers": {} + } + ] + }, + "queue-end-valid": { + "status": 200, + "body": "{\"ok\":true}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "DELETE", + "url": "http://lb.test/queue/ticket", + "body": "", + "headers": {} + } + ] + }, + "queue-end-empty": { + "status": 200, + "body": "{\"ok\":true}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "queue-end-invalid-json": { + "status": 200, + "body": "{\"ok\":true}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "queue-end-array": { + "status": 200, + "body": "{\"ok\":true}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "queue-end-null-id": { + "status": 200, + "body": "{\"ok\":true}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "queue-end-numeric-id": { + "status": 500, + "body": "Internal Server Error", + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "upstream": [] + }, + "reasoning-missing": { + "status": 422, + "body": "{\"detail\":[{\"type\":\"missing\",\"loc\":[\"body\",\"transcript\"],\"msg\":\"Field required\",\"input\":{}}]}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "reasoning-proxy": { + "status": 409, + "body": "{\"detail\":\"El servicio de razonamiento está deshabilitado en el modo PROXY.\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "reasoning-proxy-blank": { + "status": 409, + "body": "{\"detail\":\"El servicio de razonamiento está deshabilitado en el modo PROXY.\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "reasoning-OPENAI-legacy-unbound-token": { + "status": 500, + "body": "Internal Server Error", + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "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", + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "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\"}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://profile.test/user?phoneNumber=123", + "body": "", + "headers": {} + }, + { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "body": "{\"model\":\"gpt-4.1-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"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.\\n\\nUser context:\\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.\\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.\\n\\nReply only with the final assistant answer text. Keep the answer natural and concise in this language: es.\"},{\"role\":\"user\",\"content\":\"hello\"}]}", + "headers": { + "authorization": "Bearer fixture-key", + "content-type": "application/json" + } + } + ] + }, + "apipilot-disabled-GET": { + "status": 501, + "body": "{\"success\":false,\"data\":null,\"error\":{\"code\":\"APIPILOT_NOT_CONFIGURED\",\"message\":\"ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).\",\"status_code\":501,\"raw\":null}}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "apipilot-disabled-POST": { + "status": 501, + "body": "{\"success\":false,\"data\":null,\"error\":{\"code\":\"APIPILOT_NOT_CONFIGURED\",\"message\":\"ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).\",\"status_code\":501,\"raw\":null}}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "tools-query-validation": { + "status": 422, + "body": "{\"detail\":[{\"type\":\"bool_parsing\",\"loc\":[\"query\",\"refresh\"],\"msg\":\"Input should be a valid boolean, unable to interpret input\",\"input\":\"invalid\"}]}", + "headers": { + "content-type": "application/json" + }, + "upstream": [] + }, + "tools-list": { + "status": 200, + "body": "{\"success\":true,\"data\":{\"count\":1,\"include_unauthorized\":false,\"tools\":[{\"name\":\"fixture_tool\",\"description\":\"A fixture tool.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"count\":{\"type\":\"integer\"}},\"required\":[\"count\"]},\"requires_auth\":false}]},\"error\":null}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://tools.test/apipilot/tools?include_unauthorized=false", + "body": "", + "headers": { + "authorization": "Bearer fixture-token", + "x-demo-session": "fixture-session" + } + } + ] + }, + "tools-result": { + "status": 200, + "body": "{\"success\":true,\"data\":{\"count\":1,\"include_unauthorized\":false,\"tools\":[{\"name\":\"fixture_tool\",\"description\":\"A fixture tool.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"count\":{\"type\":\"integer\"}},\"required\":[\"count\"]},\"requires_auth\":false}]},\"error\":null}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://tools.test/apipilot/tools?include_unauthorized=false", + "body": "", + "headers": { + "authorization": "Bearer fixture-token", + "x-demo-session": "fixture-session" + } + } + ] + }, + "tools-envelope": { + "status": 200, + "body": "{\"success\":true,\"data\":{\"count\":1,\"include_unauthorized\":false,\"tools\":[{\"name\":\"fixture_tool\",\"description\":\"A fixture tool.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"count\":{\"type\":\"integer\"}},\"required\":[\"count\"]},\"requires_auth\":false}]},\"error\":null}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://tools.test/apipilot/tools?include_unauthorized=false", + "body": "", + "headers": { + "authorization": "Bearer fixture-token", + "x-demo-session": "fixture-session" + } + } + ] + }, + "tools-opaque": { + "status": 200, + "body": "{\"success\":true,\"data\":{\"count\":0,\"include_unauthorized\":false,\"tools\":[]},\"error\":null}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://tools.test/apipilot/tools?include_unauthorized=false", + "body": "", + "headers": { + "authorization": "Bearer fixture-token", + "x-demo-session": "fixture-session" + } + } + ] + }, + "tools-upstream-error": { + "status": 502, + "body": "{\"success\":false,\"data\":null,\"error\":{\"code\":\"APIPILOT_BAD_UPSTREAM\",\"message\":\"Backend ApiPilot tools devolvió HTTP 403\",\"status_code\":403}}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://tools.test/apipilot/tools?include_unauthorized=true", + "body": "", + "headers": {} + } + ] + }, + "execute-discovery-error-envelope": { + "status": 502, + "body": "{\"detail\":{\"code\":\"APIPILOT_BAD_UPSTREAM\",\"message\":\"Backend ApiPilot tools devolvió HTTP 503\",\"status_code\":503}}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://tools.test/apipilot/tools?include_unauthorized=true", + "body": "", + "headers": {} + } + ] + }, + "execute-unknown-tool": { + "status": 404, + "body": "{\"success\":false,\"data\":null,\"error\":{\"code\":\"TOOL_NOT_FOUND\",\"message\":\"Herramienta «unknown» no encontrada en el catálogo ApiPilot. Actualice la lista de herramientas y vuelva a intentarlo.\",\"status_code\":404,\"raw\":null}}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://tools.test/apipilot/tools?include_unauthorized=true", + "body": "", + "headers": {} + }, + { + "method": "GET", + "url": "http://tools.test/apipilot/tools?include_unauthorized=true", + "body": "", + "headers": {} + } + ] + }, + "execute-missing-argument": { + "status": 400, + "body": "{\"success\":false,\"data\":null,\"error\":{\"code\":\"INVALID_ARGUMENTS\",\"message\":\"Falta el parámetro requerido: count\",\"status_code\":400,\"raw\":{\"removed\":[]}}}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://tools.test/apipilot/tools?include_unauthorized=true", + "body": "", + "headers": {} + } + ] + }, + "execute-api-key-precheck": { + "status": 401, + "body": "{\"success\":false,\"data\":null,\"error\":{\"code\":\"AUTH_REQUIRED\",\"message\":\"La herramienta «fixture_tool» requiere autenticación. Inicie sesión o proporcione un token para continuar.\",\"status_code\":401,\"raw\":null}}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://tools.test/apipilot/tools?include_unauthorized=true", + "body": "", + "headers": { + "authorization": "Bearer fixture-key", + "x-demo-session": "fixture-key", + "x-api-key": "fixture-key" + } + } + ] + }, + "execute-envelope": { + "status": 200, + "body": "{\"success\":true,\"data\":{\"future\":[1,null]},\"error\":null,\"future_envelope\":true}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://tools.test/apipilot/tools?include_unauthorized=true", + "body": "", + "headers": {} + }, + { + "method": "POST", + "url": "http://tools.test/apipilot/execute", + "body": "{\"name\":\"fixture_tool\",\"arguments\":{\"count\":\"wrong-type-is-accepted\"}}", + "headers": { + "content-type": "application/json" + } + } + ] + }, + "execute-raw-list": { + "status": 200, + "body": "{\"success\":true,\"data\":[{\"future\":true}],\"error\":null}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://tools.test/apipilot/tools?include_unauthorized=true", + "body": "", + "headers": {} + }, + { + "method": "POST", + "url": "http://tools.test/apipilot/execute", + "body": "{\"name\":\"fixture_tool\",\"arguments\":{\"count\":\"wrong-type-is-accepted\"}}", + "headers": { + "content-type": "application/json" + } + } + ] + }, + "execute-minimal-envelope": { + "status": 200, + "body": "{\"success\":true}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://tools.test/apipilot/tools?include_unauthorized=true", + "body": "", + "headers": {} + }, + { + "method": "POST", + "url": "http://tools.test/apipilot/execute", + "body": "{\"name\":\"fixture_tool\",\"arguments\":{\"count\":\"wrong-type-is-accepted\"}}", + "headers": { + "content-type": "application/json" + } + } + ] + }, + "execute-error": { + "status": 409, + "body": "{\"success\":false,\"data\":null,\"error\":{\"status_code\":409,\"code\":\"FIXTURE\",\"message\":\"Fixture\",\"future\":true}}", + "headers": { + "content-type": "application/json" + }, + "upstream": [ + { + "method": "GET", + "url": "http://tools.test/apipilot/tools?include_unauthorized=true", + "body": "", + "headers": {} + }, + { + "method": "POST", + "url": "http://tools.test/apipilot/execute", + "body": "{\"name\":\"fixture_tool\",\"arguments\":{\"count\":\"wrong-type-is-accepted\"}}", + "headers": { + "content-type": "application/json" + } + } + ] + } + }, + "openapi_requests": { + "operations": { + "GET /api/config": { + "operationId": "config_api_config_get" + }, + "GET /api/apipilot/tools": { + "operationId": "apipilot_list_tools_api_apipilot_tools_get", + "parameters": [ + { + "name": "include_unauthorized", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": true, + "title": "Include Unauthorized" + } + }, + { + "name": "refresh", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Refresh" + } + } + ] + }, + "POST /api/apipilot/execute": { + "operationId": "apipilot_execute_api_apipilot_execute_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApipilotExecuteRequest" + } + } + }, + "required": true + } + }, + "POST /api/reasoning": { + "operationId": "reasoning_api_reasoning_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReasoningRequest" + } + } + }, + "required": true + } + }, + "GET /api/user/phone": { + "operationId": "user_by_phone_api_user_phone_get", + "parameters": [ + { + "name": "phoneNumber", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Phonenumber" + } + } + ] + }, + "GET /api/user/reports": { + "operationId": "user_reports_api_user_reports_get", + "parameters": [ + { + "name": "phoneNumber", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Phonenumber" + } + } + ] + }, + "POST /api/search": { + "operationId": "search_api_search_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchRequest" + } + } + }, + "required": true + } + }, + "POST /api/session": { + "operationId": "session_api_session_post" + }, + "GET /api/queue/{queue_id}": { + "operationId": "queue_status_api_queue__queue_id__get", + "parameters": [ + { + "name": "queue_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Queue Id" + } + } + ] + }, + "DELETE /api/queue/{queue_id}": { + "operationId": "queue_leave_api_queue__queue_id__delete", + "parameters": [ + { + "name": "queue_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Queue Id" + } + } + ] + }, + "POST /api/queue/end": { + "operationId": "queue_end_api_queue_end_post" + } + }, + "models": { + "SearchRequest": { + "properties": { + "query": { + "type": "string", + "title": "Query" + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Key" + } + }, + "type": "object", + "required": [ + "query" + ], + "title": "SearchRequest" + }, + "ReasoningRequest": { + "properties": { + "transcript": { + "type": "string", + "title": "Transcript" + }, + "instructions": { + "type": "string", + "title": "Instructions", + "default": "" + }, + "language": { + "type": "string", + "title": "Language", + "default": "" + }, + "user_context": { + "type": "string", + "title": "User Context", + "default": "" + }, + "phoneNumber": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Phonenumber" + }, + "episode_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Episode Id" + } + }, + "type": "object", + "required": [ + "transcript" + ], + "title": "ReasoningRequest" + }, + "ApipilotExecuteRequest": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "arguments": { + "additionalProperties": true, + "type": "object", + "title": "Arguments", + "default": {} + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "ApipilotExecuteRequest", + "description": "Body for the ApiPilot execute proxy endpoint. Mirrors §2.2 request shape." + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + } + }, + "realtime": [ + { + "binary": "00010203" + }, + "{\"type\": \"future.event\", \"future\": {\"nested\": [null, 1]}}", + "{\"type\": \"response.text.done\", \"text\": \"Hello world\", \"future\": true}" + ] +} diff --git a/s2s-client/tests/test_compatibility.py b/s2s-client/tests/test_compatibility.py new file mode 100644 index 0000000..05f471e --- /dev/null +++ b/s2s-client/tests/test_compatibility.py @@ -0,0 +1,275 @@ +"""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 importlib +import json +import os +from pathlib import Path +import sys +import unittest +from unittest.mock import AsyncMock, patch + +import httpx +from fastapi.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_value=False): + server = importlib.import_module("server") + +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": "", + "NODE_RED_REASONING_URL": "", + "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}, {}, [] + 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-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": "NODE_RED", "NODE_RED_REASONING_URL": "http://reasoning.test"} + reasoning_request = {"json": {"transcript": " hello ", "phoneNumber": "123", "instructions": "Fixture instructions", "language": "es", "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", "/api/reasoning", reasoning_request, { + **reasoning_config, "S2S_REASONING_TYPE": "OPENAI", "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.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", "x-s2s-caller", + ) 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(server, **{**BASE_CONFIG, **config}), + patch.object(server, "_APIPILOT_CACHE", {}), + patch.object(server, "_build_today_fixed_context_block", return_value="Fixture current date: 2026-01-04"), + patch.object(server.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"}) + + with patch.object(server, "_forward_agent_text_to_nodered", new_callable=AsyncMock): + await server._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_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"]))