Compare commits

..
1 Commits
Author SHA1 Message Date
alvaro dfed03ac7c chore(vocero): remove the legacy ApiPilot integration (#3)
Vocero now retrieves AquaPortal content through authenticated MCP. This removes the obsolete ApiPilot integration from the API and browser while preserving the separately deployed speech pipeline.

Release notes:

- Removed ApiPilot endpoints, configuration, credential overrides, and HTTP/realtime tool execution.
- Removed browser ApiPilot catalog loading and prompt generation while retaining authenticated MCP discovery.
- Updated regression coverage and operating documentation for the retired routes and settings.

Reviewed-on: AquaPortal/s2s#3
2026-10-08 11:23:41 -04:00
39 changed files with 198 additions and 3314 deletions
+2 -2
View File
@@ -43,7 +43,7 @@ URLs, not filesystem paths.
| `vocero-api/vocero_api/api/` | HTTP routes, route registration, and response validation helper |
| `vocero-api/vocero_api/models/` | Pydantic request and response contracts |
| `vocero-api/vocero_api/core/` | Environment parsing, logging, public URLs, and startup diagnostics |
| `vocero-api/vocero_api/providers/` | ApiPilot and caller/profile/report HTTP integrations |
| `vocero-api/vocero_api/providers/` | Authenticated MCP transport, backend login, and caller/profile/report HTTP integrations |
| `vocero-api/vocero_api/services/` | Prompt/context construction, reasoning, and tool-context rendering |
| `vocero-api/vocero_api/realtime/` | WebSocket relay, event handling, marker filtering, and audio forwarding |
| `vocero-frontend/` | React, TypeScript, Tailwind, and Vite browser application |
@@ -139,7 +139,7 @@ evidence; do not bundle them into tooling or structural refactors.
migration at `3d42df5`. `vocero-api/tests/test_compatibility.py` compares HTTP
response bytes, statuses, selected headers, outgoing requests, request schemas,
and realtime frames. Do not regenerate captures merely to make a refactor pass.
- Known reasoning failures, ApiPilot authentication/cache quirks, and relay
- Known reasoning failures and relay
behavior are documented in the compatibility guide. Preserve narrow lint/type
exceptions that retain those behaviors until a deliberate fix is in scope.
- The pytest warning filter is only for Starlette TestClient's exact deprecated
+1 -1
View File
@@ -7,7 +7,7 @@ documentation, source identifiers, comments, and internal instructions use Engli
| Component | Purpose |
|---|---|
| [vocero-frontend](vocero-frontend) | React, TypeScript, and Tailwind browser UI, served by Vite with hot reload |
| [vocero-api](vocero-api) | FastAPI backend, realtime WebSocket proxy, search and ApiPilot bridges |
| [vocero-api](vocero-api) | FastAPI backend, realtime WebSocket proxy, search and authenticated AquaPortal MCP |
| [speech_to_speech](speech_to_speech) | Queue-driven VAD, speech recognition, language model, and speech synthesis pipeline |
Start with [client setup and usage](docs/client/usage.md), then read the
+5 -7
View File
@@ -15,14 +15,14 @@ flowchart LR
Server[Client FastAPI server]
Speech[Realtime speech backend]
Reasoning[OpenAI-compatible HTTP reasoning provider]
Tools[ApiPilot backend]
Tools[AquaPortal MCP backend]
Browser <-->|Frontend and HMR| Vite
Vite -->|HTTP configuration and proxies| Server
Vite <-->|Speech WebSocket relay| Server
Server <-->|Realtime events and audio| Speech
Browser <-->|Optional direct connection| Speech
Server -->|Optional transcript reasoning| Reasoning
Server -->|Tool discovery and execution| Tools
Server -->|Authenticated tool discovery and execution| Tools
```
## Connection modes
@@ -34,8 +34,8 @@ session/queue proxy, or the user can enter a direct URL in settings. See
[usage](usage.md) for the configuration matrix.
The local realtime proxy relays audio and events in both directions. It also
patches selected `session.update` instructions, handles ApiPilot marker-based
tool execution, filters internal text, and can suppress speculative/tool-marker
patches selected `session.update` instructions, handles authenticated MCP
tool execution when enabled, filters internal text, and suppresses MCP tool-marker
audio. It is not an unconditional byte-for-byte tunnel.
## Source map
@@ -47,7 +47,7 @@ audio. It is not an unconditional byte-for-byte tunnel.
| [api](../../vocero-api/vocero_api/api) | HTTP route registration, handlers and compatible response validation |
| [models](../../vocero-api/vocero_api/models) | Pydantic request and response contracts |
| [core](../../vocero-api/vocero_api/core) | Existing environment parsing, logging, URL helpers and diagnostics |
| [providers](../../vocero-api/vocero_api/providers) | Caller context and ApiPilot HTTP integrations |
| [providers](../../vocero-api/vocero_api/providers) | Authenticated MCP transport, backend login, and caller HTTP integrations |
| [services](../../vocero-api/vocero_api/services) | Context construction, reasoning and tool-context rendering |
| [realtime](../../vocero-api/vocero_api/realtime) | WebSocket relay, events, markers and audio handling |
| [start_ui.sh](../../start_ui.sh) | Dependency setup, ports, readiness, reload processes, and shutdown |
@@ -74,8 +74,6 @@ audio. It is not an unconditional byte-for-byte tunnel.
| `GET /api/mcp/tools` | Discover the selected conversation's authenticated MCP catalog |
| `GET /api/user/phone` | Configured caller profile lookup |
| `GET /api/user/reports` | Configured scheduled report lookup, subject to report-injection controls |
| `GET /api/apipilot/tools` | Tool discovery with optional authorization and cache refresh |
| `POST /api/apipilot/execute` | Validated tool execution returning a `success/data/error` envelope |
| `POST /api/search` | Serper web search proxy |
| `/api/session`, `/api/queue/*` | Load-balancer session and waiting-queue proxies |
+9 -10
View File
@@ -27,10 +27,10 @@ episode, report and prior-conversation context. It also controls whether the
browser appends caller profile text to its realtime instructions.
It is not a master switch for every context source. Operator-supplied RAG text
and configured ApiPilot discovery work independently. `S2S_INJECT_TODAY`
and authenticated MCP orchestration work independently. `S2S_INJECT_TODAY`
defaults to true; date and voice-output guards are built on paths that invoke
instruction augmentation or HTTP context assembly. A WebSocket session with no
profile/episode/report/summary or ApiPilot block skips proxy augmentation.
profile/episode/report/summary block skips non-MCP proxy augmentation.
Direct browser connections bypass server-side WebSocket injection entirely.
| Setting | Default | Effect |
@@ -51,7 +51,6 @@ Direct browser connections bypass server-side WebSocket injection entirely.
| Scheduled reports | `S2S_REPORT_LIST`, profile gate and report gate | Newest N labels/dates by default; full mode includes scripts with a 12,000-character block limit |
| Latest conversation | `S2S_LATEST_SUMMARY_TEXT`, or `S2S_LATEST_SUMMARY` with a phone number | Up to 1,600 characters; static text overrides HTTP fetch |
| Custom RAG | Operator applies text through the RAG dialog | Up to 12,000 characters; appended in browser instructions and HTTP `user_context` |
| ApiPilot discovery | Configured `S2S_APIPILOT_TOOLS_URL` | Tool names/descriptions/auth requirements and call-marker rules, independent of the profile gate |
### Episode
@@ -113,25 +112,25 @@ session. This context does not require an external retrieval endpoint.
At local WebSocket connect, the server resolves enabled context and stores it in
`session_ctx`. Each client `session.update` then passes through
`_patch_client_session_update_with_profile()`. Other client frame types are not
modified by this helper. The broader relay still performs ApiPilot handling and
text/audio filtering.
modified by this helper. The non-MCP relay retains its general assistant-text
filtering and forwards audio without a tool-execution pass.
The current augmentation function **keeps base instructions first**, then
appends surviving voice-output guards, date context, episode, profile, reports,
and summary blocks. ApiPilot discovery is appended separately. If a block type
is already present in the base, deduplication may omit its server candidate.
and summary blocks. If a block type is already present in the base, deduplication
may omit its server candidate.
This describes string assembly, not a guarantee of model attention or priority.
Every HTTP reasoning request rebuilds enabled context. Its `user_context`
assembly orders voice guard, date, episode, profile, reports, summary, ApiPilot
assembly orders voice guard, date, episode, profile, reports, summary,
and browser-provided context, then deduplicates recognized types and strips
internal markers. Server instruction augmentation remains separate, so avoid
assuming the WebSocket and HTTP prompts have identical ordering.
Context markers support internal deduplication and diagnostics; ordinary
profile/report/episode/history sentinels are stripped before final augmentation.
ApiPilot call/catalog markers have their own handling. Treat the source helpers
as the reference for the exact final payload.
MCP uses its separate conversation context and call-marker handling described
above. Treat the source helpers as the reference for the exact final payload.
## Failure behavior and inspection
+19 -10
View File
@@ -45,7 +45,7 @@ and the retained `server:app` shim; use `--app-dir vocero-api` from the root.
## Schema boundaries
`models/requests.py` retains the existing search, reasoning, and ApiPilot
`models/requests.py` retains the existing search and reasoning
request models, including their defaults, coercion, and ignored extra fields.
The queue beacon still accepts malformed JSON and non-object bodies as before;
its Pydantic view is applied only after the original object check, and
@@ -54,9 +54,10 @@ its Pydantic view is applied only after the original object check, and
`models/responses.py` validates structure constructed by this server. Fields
owned by external services remain `Any` where the old parser imposed no type
constraint. Object models allow extra fields. Caller profiles can be arbitrary
JSON; load-balancer fields remain optional; execution results and error details
remain open. Existing provider parsing, authentication precedence, argument
filtering, environment parsing, and WebSocket frames are unchanged.
JSON; load-balancer fields remain optional. Existing provider parsing,
authentication precedence, environment parsing, and WebSocket frames are
preserved outside explicitly retired integrations. Authenticated conversation
and MCP contracts live in `models/mcp.py` and `models/content.py`.
HTTP handlers call `validated_json_response`: Pydantic validates the payload,
then `JSONResponse` serializes the original object. Returning a dumped model
@@ -69,19 +70,23 @@ existing error shapes; there is no new common error envelope.
## Compatibility evidence
`tests/fixtures/legacy-contracts.json` was captured from the original monolithic
server at commit `3d42df5`, before refactoring. Tests compare 82 HTTP scenarios,
server at commit `3d42df5`, before refactoring. Tests compare 66 HTTP scenarios,
including exact response text, statuses, selected headers, and outgoing mock
HTTP requests. They also compare request OpenAPI definitions and representative
realtime frames. Additional tests cover response validation and permissive
upstream values; the original local HTTP/WebSocket tests remain in the suite.
Four captures belonging exclusively to the retired workflow reasoning service
were removed with that integration; all retained captures are unchanged.
were removed with that integration. ApiPilot retirement removed another 16 HTTP
captures, its two OpenAPI operations, and its request model. The four bootstrap
captures now omit the retired `apipilot` flag. One reasoning failure case was
renamed because removing ApiPilot eliminated its token access; it still exposes
the existing uninitialized profile failure. All other retained capture values
are unchanged.
The captures deliberately preserve unknown fields, omitted fields, unusual JSON
values, queue beacon edge cases, permissive tool arguments, and known failures.
Examples include reasoning's existing uninitialized local variables in certain
configuration combinations, API-key-only tool precheck behavior, the catalog
cache's existing key, and relay termination after a suppressed invalid
values, queue beacon edge cases, and known failures. Examples include reasoning's
existing uninitialized profile in certain non-MCP configuration combinations
and relay termination after a suppressed invalid
`session.update` error. Fix those in separately reviewed behavior changes;
do not regenerate golden captures to make a refactor pass.
@@ -90,3 +95,7 @@ provider responses, live speech models, or production configuration. No deployed
services or real credentials are needed. The Docker check verifies the actual
runtime image, API configuration, documentation, OpenAPI, and the backend's
existing root 404; it removes its temporary container and image afterward.
ApiPilot removal deliberately excludes `speech_to_speech/`. The MCP orchestration
test still exercises its existing pure marker sanitizer without importing model
dependencies, verifying that `VOCERO_MCP_CALL` reaches the Vocero relay unchanged.
+2 -2
View File
@@ -22,8 +22,8 @@ names shown in a deployment are configuration examples, not definitions.
| Tool executor | Code that receives a tool call, executes it and returns the result to the model |
| Web search | The browser-supported native tool whose request goes through the server's `/api/search` proxy |
| Search proxy | Same-origin server route holding a deployment search key and calling the external provider |
| ApiPilot | The dynamic-tool discovery/execution bridge, with both HTTP endpoints and model-text call markers |
| Call marker | Internal structured text such as `<<<APIPILOT_CALL:...>>>` used by the ApiPilot bridge; separate from native function-call events |
| AquaPortal MCP | Authenticated tool discovery and execution using the current conversation's backend session |
| Call marker | Internal structured text such as `<<<VOCERO_MCP_CALL:...>>>` used by the MCP orchestrator; separate from native function-call events |
| Tools panel | Controls for available browser tools and search-key configuration |
| Settings | Connection, voice, audio and instruction controls persisted in browser storage |
| Identity block | The header's product/user identity, separate from configuration dialogs |
+15 -10
View File
@@ -19,8 +19,9 @@ with `config.primaryPrompt || settings.instructions`. `/api/config` pins
`DEFAULT_INSTRUCTIONS` is used. The current server does not fetch a remote agent
profile to choose this base prompt.
The browser adds caller context when `injectProfile` is enabled, custom RAG text
when the operator has applied it, and an available ApiPilot catalog block.
The browser adds caller context when `injectProfile` is enabled and custom RAG
text when the operator has applied it. In MCP mode, the server constructs tool
instructions from the authenticated conversation's catalog and content selection.
`speechInstructions()` adds the speech-rendering hint only in `OPENAI` mode.
The WebSocket client packages these instructions in `session.update.session`;
the local proxy may then add server-resolved context. Details belong in
@@ -30,13 +31,14 @@ the local proxy may then add server-resolved context. Details belong in
The realtime backend receives session instructions and enabled native tools,
then processes each voice turn through STT, model generation and TTS.
Native function-call events are executed by the browser. ApiPilot also has a
Native function-call events are executed by the browser. Authenticated MCP has a
separate marker-based path handled by the local proxy. These are distinct
protocols, described in [tool calling](tool-calling.md).
Using a direct browser URL bypasses local WebSocket proxy processing. Browser
instructions still apply, but server-side session context and marker execution
require the local `/v1/realtime` route.
require the local `/v1/realtime` route. MCP in `PROXY` mode requires this relay;
configuration rejects a pinned direct URL or load-balancer bypass.
## OpenAI-compatible HTTP reasoning
@@ -53,12 +55,14 @@ is a runtime default, not a recommendation about provider availability.
`VoiceController.reason()` in [controller.ts](../../vocero-frontend/src/realtime/controller.ts)
runs after a complete user transcript and posts a body with `transcript`,
`instructions`, `language`, `user_context`, `phoneNumber`, and `episode_id`.
The server combines instructions and context into model messages, requests a
final answer with a 30-second timeout, and returns `answer`. ApiPilot can
append discovery instructions, execute a detected call marker, and request a
second response based on the tool result, including available `usage_pass2`
metadata. The endpoint does not forward the browser's native `session.tools`
catalog as OpenAI Chat Completions `tools`.
Without MCP, the server combines instructions and context into model messages,
requests a final answer with a 30-second timeout, and returns `answer`.
With MCP, the browser also sends conversation and turn headers. The server uses
that conversation's backend token, linked/current content, and bounded history
to run tool selections before returning `answer`. This path has a 180-second
total timeout and configured per-operation limits. Retired ApiPilot execution
metadata is no longer returned. The endpoint does not forward the browser's
native `session.tools` catalog as OpenAI Chat Completions `tools`.
For an external answer, the browser cancels/suppresses the active native
response and appends the external assistant text. It uses browser
@@ -78,5 +82,6 @@ realtime backend instead.
| Change the base agent prompt | `S2S_PRIMARY_PROMPT` or browser settings when unpinned |
| Change browser context assembly | `buildInstructions()` in `vocero-frontend/src/lib/context.ts` |
| Change HTTP provider messages | `reasoning()` in `vocero-api/vocero_api/services/reasoning.py` |
| Change MCP reasoning or tool context | `vocero-api/vocero_api/services/mcp_reasoning.py` and `vocero-api/vocero_api/services/mcp_tools.py` |
| Change external-answer rendering | `VoiceController.reason()` and `RealtimeClient.renderExternalAnswer()` |
| Change native model/tool generation | `speech_to_speech/LLM` and realtime handlers |
+17 -54
View File
@@ -2,7 +2,7 @@
The application supports native realtime function calling and server-side
tool selection from model text. AquaPortal content uses authenticated MCP when
`S2S_MCP_URL` is set; otherwise the legacy ApiPilot bridge remains available.
`S2S_MCP_URL` is set. Without MCP, the native browser tools remain available.
A spoken promise to fetch something is not evidence that a tool executed.
## Authenticated AquaPortal MCP
@@ -172,69 +172,32 @@ result should be spoken. A `function_call_output` item alone does not start
generation. See the [realtime API guide](../speech/realtime-api.md) for local
model `<code>` extraction and native remote-provider tool calls.
## ApiPilot discovery and execution
When MCP is disabled, `S2S_APIPILOT_TOOLS_URL` enables the legacy dynamic-tool bridge. The backend
discovers tools through its `/tools` route and executes through `/execute`.
`S2S_APIPILOT_TOOLS_EXECUTE_URL` can override the execution target. The same-origin UI
server exposes:
- `GET /api/apipilot/tools?include_unauthorized=true`: discovery envelope with
`data.tools`, `data.count`, and `data.include_unauthorized`.
- `POST /api/apipilot/execute`: body `{"name":"tool_name","arguments":{}}` and
a `success/data/error` envelope.
Tool definitions contain `name`, `description`, JSON Schema `parameters`, and
`requires_auth`. Execution looks up the current discovery cache, refreshes once
if the tool is missing, and applies an authorization gate and argument
validation before forwarding. Caller `Authorization` and
`x-demo-session` can be forwarded; configured service credentials and
login-by-phone provide additional server paths. Display `error.message` to the
user; keep `error.code`, `error.status_code`, and `error.raw` for diagnostics.
The discovery cache defaults to 300 seconds server-side
(`S2S_APIPILOT_TTL_SEC`) and can be refreshed with `refresh=true`. The browser
also keeps a per-user catalog cache. ApiPilot configuration is independent of
`S2S_INJECT_PROFILE`.
## Marker-based two-pass reasoning
The browser and proxy add an ApiPilot catalog as **instruction text**, including
tool names, descriptions, authentication requirements and required parameters.
The model is instructed to emit a call marker when it chooses a matching tool:
```text
<<<APIPILOT_CALL:NAME=tool_name,ARGUMENTS={"parameter":"value"}>>>
```
Tool names are exact identifiers and must not be translated. The local realtime
proxy accumulates assistant text, detects the marker, validates and executes
the ApiPilot call, then injects the result as context and requests a new
response. The HTTP reasoning route has analogous discovery/execution and
second-answer handling. Internal marker text and its speculative audio are
filtered from user output where those handlers run.
This marker is not `response.function_call_arguments.done`, and ApiPilot prompt
discovery is not the same as declaring a native `session.tools` array. Direct
browser connections bypass the local relay's marker executor even if the
browser includes catalog instructions.
## Retired documentation assumptions
ApiPilot discovery/execution endpoints, catalog injection, credential overrides,
and two-pass marker handling have been removed from Vocero. Its former HTTP
routes return 404 regardless of MCP configuration. The separate speech service
still has its existing legacy marker sanitizers; no ApiPilot requests can be
made through Vocero. See [usage](usage.md#authenticated-aquaportal-mcp) for
deployment configuration cleanup.
The former `HOWITWORKS.md` described `/api/mcp/tool`, remote
`S2S_AGENT_PROFILE` loading, and Spanish/Italian-to-English client ranking.
Those APIs/rankers are absent from the current client/server path. The retained
agent-profile JSON is an illustrative historical/example shape. Add or modify
current ApiPilot tool definitions in the provider; do not restore obsolete
endpoints solely to match older documentation.
agent-profile JSON is an illustrative historical/example shape. Content tool
definitions and permissions belong to the AquaPortal MCP backend; Vocero's
provider enforces its allowlist. Do not restore obsolete endpoints solely to
match older documentation.
## Verify a tool turn
1. Confirm `/api/config` reports the intended feature and connection mode.
2. Inspect `session.update`: native `tools` and ApiPilot instruction text are
separate evidence.
2. For MCP, confirm conversation creation and authenticated `/api/mcp/tools`
discovery succeed. Inspect the relay's `session.update` for MCP instructions;
these are separate from native `session.tools` declarations.
3. For native calls, find `response.function_call_arguments.done`, then the
browser `toolcall` and executor request.
4. For ApiPilot, find the discovery, call-marker detection and execution logs.
4. For MCP, find `mcp_tool_completed` for the expected user/tool and any
`vocero.tool.started` / `vocero.tool.failed` voice events.
5. Confirm the tool result reaches the model and follow-up text/audio reaches
the browser. A spoken acknowledgement alone does not complete the flow.
+7 -5
View File
@@ -12,7 +12,7 @@ Inspect the server configuration without printing secret environment files:
curl -fsS http://localhost:8420/api/config | jq
```
Confirm `s2sUrl`, `lb`, `reasoningType`, `injectProfile`, `apipilot`, `theme`
Confirm `s2sUrl`, `lb`, `reasoningType`, `injectProfile`, `mcp`, `theme`
and `startSessionMode`. A direct WebSocket URL bypasses the local relay's
server-side context injection and marker execution. For a local proxy, confirm
the server can resolve and reach `S2S_PROXY_TARGET`.
@@ -50,8 +50,10 @@ Follow the actual execution mechanism:
JSON-string arguments.
3. Confirm the browser dispatched `toolcall` and the executor sent its request.
4. Confirm `function_call_output` and any required `response.create` were sent.
5. For ApiPilot, inspect discovery, marker detection, authorization/schema
validation, execution, and second-pass result injection instead.
5. For MCP, inspect conversation creation, `/api/mcp/tools` discovery,
`mcp_tool_completed` logs, and the returned tool data/follow-up response.
Verify the conversation belongs to the browser and backend authorization
succeeds; do not retry with shared credentials or legacy content endpoints.
A spoken sentence such as “Voy a buscar el reporte” does not satisfy step 2.
If the function-call event is absent, inspect model/backend tool generation
@@ -59,7 +61,7 @@ before debugging a browser executor that has not been invoked.
Earlier local debug notes recorded a reproduction where tools and required
tool policy were sent but the model produced only spoken text. Those notes
refer to the retired MCP ranking path; they do not establish a current ApiPilot
refer to the retired MCP ranking path; they do not establish a current AquaPortal MCP
failure. Both local investigation notes remain marked `OPEN`. Their useful
lesson is to collect evidence at each boundary and reproduce against current
code before claiming a root cause is resolved.
@@ -114,7 +116,7 @@ Useful checks are:
The historical investigation introduced a 90ms playback prebuffer and reduced
MCP prompt reinjection. The old 150ms MCP fallback timer is historical, not a
current ApiPilot configuration knob. Verify the live worklet and current relay
current MCP configuration knob. Verify the live worklet and current relay
before changing audio timing based on those observations.
## External reasoning and startup
+18 -7
View File
@@ -2,7 +2,8 @@
The browser voice client connects to an existing OpenAI Realtime-compatible
speech backend. The UI server can also proxy WebSocket traffic, search,
reasoning, caller context, ApiPilot tools and load-balancer requests.
reasoning, caller context, authenticated AquaPortal MCP tools and load-balancer
requests.
## Local startup
@@ -126,8 +127,8 @@ translations.
Web search needs `SERPER_API_KEY` on the server or a user-supplied key in the
tools panel. The server proxies requests through `/api/search`; its own key is
not returned to the browser. Dynamic ApiPilot tools are configured through
`S2S_APIPILOT_TOOLS_URL`; see [tool calling](tool-calling.md).
not returned to the browser. AquaPortal content tools use authenticated MCP;
see [tool calling](tool-calling.md).
Operator RAG text is applied through the RAG dialog and updates live
instructions. Caller links may include `phoneNumber=%2B...`; episode links may
@@ -141,7 +142,7 @@ enabled data sources and limits.
Streamable HTTP endpoint, for example `https://backend.example.test/api/v1/mcp/`.
Set `S2S_LOGIN_BY_PHONE` to that backend's `POST /api/v1/auth/phone-login` URL.
The phone parameter remains an identity selector without proof of ownership.
MCP uses the returned opaque `sessionToken`, never an ApiPilot API key or service token.
MCP uses the returned opaque `sessionToken` for that user, with no service-token override.
For voice reasoning (`S2S_REASONING_TYPE=PROXY`), set `S2S_PROXY_TARGET` to the
speech service and leave `SPEECH_TO_SPEECH_URL` and `LOAD_BALANCER_URL` unset.
@@ -153,10 +154,20 @@ or audio-format change is required.
The backend must expose the eight-tool catalog and phone-login contract described
in [tool calling](tool-calling.md#authenticated-aquaportal-mcp). Leave
`S2S_MCP_URL` unset to retain the existing ApiPilot and caller-context paths.
`S2S_MCP_URL` unset to use the existing caller-context paths without MCP tools.
Setting it makes MCP the exclusive AquaPortal content path: legacy profile,
report, latest-summary, and episode preloads are skipped. Existing ApiPilot
and caller HTTP routes return 409 while MCP is enabled.
report, latest-summary, and episode preloads are skipped. Legacy caller HTTP
routes return 409 while MCP is enabled.
ApiPilot has been retired. `/api/apipilot/tools` and `/api/apipilot/execute`
return 404, and `/api/config` no longer includes `apipilot`. Remove obsolete
`S2S_APIPILOT_TOOLS_URL`, `S2S_APIPILOT_TOOLS_EXECUTE_URL`,
`S2S_APIPILOT_SERVICE_TOKEN`, `S2S_APIPILOT_API_KEY`, `S2S_APIPILOT_TTL_SEC`,
and `S2S_APIPILOT_TIMEOUT_SEC` from deployment configuration; they are ignored.
Keep `S2S_LOGIN_BY_PHONE`, which is still required for MCP authentication.
Deploy the API and frontend together and refresh existing browser sessions.
The separately deployed `speech_to_speech/` service is unchanged by this removal;
its existing legacy marker sanitizers remain, with no speech restart required.
| Setting | Default | Meaning |
|---|---|---|
+5
View File
@@ -46,6 +46,11 @@ the implementation at the recorded date; current operating instructions live in
### Changed
- Removed the ApiPilot API bridge, configuration, credential overrides, browser
catalog/prompt handling, and two-pass HTTP/realtime execution. AquaPortal tools
use authenticated MCP; the separate speech pipeline retains its existing
marker sanitizers.
- Use the AquaPortal admin favicon in the Vocero browser frontend.
- Start `TALK` and `TALKFIRST` conversations as soon as configuration and caller
+2 -1
View File
@@ -13,7 +13,8 @@ short_description: Voice chat over WebSocket against a HF speech-to-speech
FastAPI APIs and WebSocket relay for the [React voice frontend](../vocero-frontend).
This service handles configuration, caller context, upstream authentication,
search, reasoning, ApiPilot tools, and optional load-balancer allocation.
search, reasoning, authenticated AquaPortal MCP tools, and optional load-balancer
allocation.
The root `start_ui.sh` now serves the [React frontend](../vocero-frontend) with
Vite on public port `8420`, proxying backend requests here on loopback port
+1 -1
View File
@@ -38,7 +38,7 @@ with urllib.request.urlopen(base + "/api/config") as response:
assert json.load(response) == {
"search": False, "lb": False, "allowDirect": True, "s2sUrl": "",
"primaryPrompt": "", "reasoningType": "PROXY", "injectProfile": False,
"debugPrompt": False, "startSessionMode": "", "theme": "", "apipilot": False,
"debugPrompt": False, "startSessionMode": "", "theme": "",
}
for path in ["/docs", "/docs/README.md", "/openapi.json"]:
with urllib.request.urlopen(base + path) as response:
+5 -336
View File
@@ -2,7 +2,7 @@
"http": {
"config-default": {
"status": 200,
"body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\",\"apipilot\":false}",
"body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\"}",
"headers": {
"content-type": "application/json"
},
@@ -10,7 +10,7 @@
},
"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}",
"body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"wss://voice.test/v1/realtime\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\"}",
"headers": {
"content-type": "application/json"
},
@@ -18,7 +18,7 @@
},
"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}",
"body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"wss://voice.test/realtime?token=fixture\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\"}",
"headers": {
"content-type": "application/json"
},
@@ -26,7 +26,7 @@
},
"config-lb": {
"status": 200,
"body": "{\"search\":false,\"lb\":true,\"allowDirect\":false,\"s2sUrl\":\"\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\",\"apipilot\":false}",
"body": "{\"search\":false,\"lb\":true,\"allowDirect\":false,\"s2sUrl\":\"\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\"}",
"headers": {
"content-type": "application/json"
},
@@ -736,7 +736,7 @@
},
"upstream": []
},
"reasoning-OPENAI-legacy-unbound-token": {
"reasoning-openai-profile-disabled": {
"status": 500,
"body": "Internal Server Error",
"headers": {
@@ -775,279 +775,6 @@
}
}
]
},
"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": {
@@ -1055,44 +782,6 @@
"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": {
@@ -1264,26 +953,6 @@
],
"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": {
+1 -100
View File
@@ -19,7 +19,6 @@ from fastapi.testclient import TestClient
with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_value=False):
from vocero_api import main as server
from vocero_api.core import config as settings
from vocero_api.providers import apipilot
from vocero_api.realtime import proxy
from vocero_api.services import context
@@ -42,21 +41,10 @@ BASE_CONFIG = {
"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():
@@ -268,7 +256,7 @@ def cases():
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",
"reasoning-openai-profile-disabled",
"POST",
"/api/reasoning",
{"json": {"transcript": "hello"}},
@@ -323,91 +311,6 @@ def cases():
],
)
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 = {}
@@ -442,7 +345,6 @@ def capture_http():
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,
@@ -482,7 +384,6 @@ def capture_openapi_requests():
for name in (
"SearchRequest",
"ReasoningRequest",
"ApipilotExecuteRequest",
"HTTPValidationError",
"ValidationError",
)
+2 -2
View File
@@ -143,8 +143,8 @@ def test_http_chains_tools_retains_history_and_ignores_browser_identity(enabled,
assert client.post("/api/reasoning", json=body).status_code == 401
assert client.get("/api/user/phone?phoneNumber=123").status_code == 409
assert client.get("/api/user/reports").status_code == 409
assert client.get("/api/apipilot/tools").status_code == 409
assert client.post("/api/apipilot/execute", json={"name": "get_report"}).status_code == 409
assert client.get("/api/apipilot/tools").status_code == 404
assert client.post("/api/apipilot/execute", json={"name": "get_report"}).status_code == 404
enabled.assert_awaited_once_with("+56000000001")
+3 -8
View File
@@ -5,14 +5,13 @@ from fastapi.responses import JSONResponse
from pydantic import ValidationError
from vocero_api.api.responses import validated_json_response
from vocero_api.models.requests import ApipilotExecuteRequest, QueueEndPayload, SearchRequest
from vocero_api.models.requests import QueueEndPayload, SearchRequest
from vocero_api.models.responses import (
AllocationResponse,
QueueCancellationResponse,
ReasoningResponse,
ReportsResponse,
SearchResponse,
ToolExecutionResponse,
UserProfileResponse,
)
@@ -22,9 +21,7 @@ from vocero_api.models.responses import (
[
(AllocationResponse, {}),
(AllocationResponse, {"state": [1, None], "connectUrl": "wss://host", "future": {"x": 1.0}}),
(ToolExecutionResponse, {"success": True, "unknown": {"nested": [None, 1.0, False]}}),
(ToolExecutionResponse, {"success": False, "data": 42, "error": "loose error", "future": []}),
(ReasoningResponse, {"answer": "á", "usage_pass2": [1, None], "future": True}),
(ReasoningResponse, {"answer": "á", "future": [1, None, True]}),
(SearchResponse, {"query": "q", "answer": {"x": 2}, "results": [{"title": [], "snippet": 7, "url": None}]}),
(
ReportsResponse,
@@ -47,10 +44,8 @@ def test_owned_response_fields_are_actually_validated(payload):
validated_json_response(payload, QueueCancellationResponse)
def test_request_models_keep_legacy_extra_field_and_argument_behavior():
def test_request_models_keep_legacy_extra_field_and_beacon_behavior():
assert SearchRequest.model_validate({"query": "q", "future": True}).model_dump() == {"query": "q", "key": None}
arguments = {"arbitrary": {"nested": [1, None]}, "unknown": True}
assert ApipilotExecuteRequest.model_validate({"name": "tool", "arguments": arguments}).arguments == arguments
for value in [None, "id", 42, False, [], {}, {"future": True}]:
assert QueueEndPayload.model_validate({"queueId": value, "future": 1}).queueId == value
assert QueueEndPayload.model_validate({}).queueId == ""
+24 -36
View File
@@ -7,10 +7,9 @@ import json
import os
import threading
import unittest
from unittest.mock import ANY, AsyncMock, Mock, patch
from unittest.mock import AsyncMock, Mock, patch
import httpx
from fastapi import Request
from fastapi.testclient import TestClient
from websockets.sync.server import serve
@@ -18,8 +17,7 @@ from websockets.sync.server import serve
with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_value=False):
from vocero_api import main as server
from vocero_api.core import config as settings
from vocero_api.providers import apipilot, caller
from vocero_api.services import tool_context
from vocero_api.providers import caller
GRANT = {
@@ -182,34 +180,27 @@ class BackendContractTests(unittest.TestCase):
self.assertEqual(lookup.headers["authorization"], "Bearer fixture-token")
self.assertEqual(lookup.url.params["phoneNumber"], "+56000000000")
def test_caller_auth_headers_remain_independent(self):
request = Request(
{
"type": "http",
"headers": [
(b"authorization", b"Bearer fixture-token"),
(b"x-demo-session", b"fixture-demo"),
],
}
)
self.assertEqual(apipilot._apipilot_parse_authorization(request), ("Bearer fixture-token", "fixture-demo"))
def test_apipilot_routes_and_contracts_are_retired(self):
self.assertNotIn("apipilot", self.client.get("/api/config").json())
spec = self.client.get("/openapi.json").json()
for method, path in (("GET", "/api/apipilot/tools"), ("POST", "/api/apipilot/execute")):
self.assertNotIn(path, spec["paths"])
response = self.client.request(method, path, json={"name": "get_report"})
self.assertEqual(response.status_code, 404)
schemas = spec["components"]["schemas"]
for name in ("ApipilotExecuteRequest", "ToolCatalogResponse", "ToolExecutionResponse", "ToolInvocation"):
self.assertNotIn(name, schemas)
self.assertNotIn("apipilot", schemas["ConfigResponse"]["properties"])
self.assertEqual(set(schemas["ReasoningResponse"]["properties"]), {"answer"})
self.upstream.assert_not_called()
class VoiceProxyTests(unittest.TestCase):
def test_apipilot_catalog_and_caller_defaults_reach_the_voice_session(self):
def test_caller_and_episode_context_reach_the_voice_session(self):
closed = threading.Event()
catalog = AsyncMock(
return_value=[
{
"name": "get_caller_briefing",
"description": "Retrieve the caller's local test briefing.",
"requires_auth": True,
"parameters": {"required": ["phone", "episode_id", "email"]},
}
]
)
login = AsyncMock(return_value=("fixture-token", {}))
profile = AsyncMock(return_value={"user": {"email": "caller@example.test"}})
profile = AsyncMock(return_value={"user": {"name": "Local Caller", "email": "caller@example.test"}})
episode = AsyncMock(return_value={"title": "Local episode", "text": "Local episode text"})
def voice(socket):
try:
@@ -226,13 +217,12 @@ class VoiceProxyTests(unittest.TestCase):
target = f"ws://127.0.0.1:{upstream.socket.getsockname()[1]}/v1/realtime"
with (
patch.object(settings, "S2S_PROXY_TARGET", target),
patch.object(settings, "S2S_INJECT_PROFILE", False),
patch.object(settings, "S2S_APIPILOT_TOOLS_URL", "http://catalog.test/tools"),
patch.object(settings, "S2S_INJECT_PROFILE", True),
patch.object(settings, "S2S_LOGIN_BY_PHONE", "http://profile.test/login"),
patch.object(settings, "VOCERO_USER_PHONE_AUTH_URL", "http://profile.test/user"),
patch.object(apipilot, "_apipilot_fetch_tools", catalog),
patch.object(caller, "_login_by_phone", login),
patch.object(caller, "_fetch_user_profile_for_phone", profile),
patch.object(caller, "_fetch_podcast_episode", episode),
patch.object(httpx, "AsyncClient", side_effect=AssertionError("Unexpected HTTP request")),
TestClient(server.app) as client,
):
@@ -246,11 +236,9 @@ class VoiceProxyTests(unittest.TestCase):
instructions = update["session"]["instructions"]
for expected in (
"Local fixture",
tool_context.APIPILOT_PASS1_LIST_MARKER,
"name: get_caller_briefing",
'phone = "+56000000000"',
'episode_id = "fixture-episode"',
'email = "caller@example.test"',
"Local Caller",
"Local episode text",
"caller@example.test",
):
self.assertIn(expected, instructions)
# Configuration injection must preserve ordinary audio forwarding.
@@ -260,7 +248,7 @@ class VoiceProxyTests(unittest.TestCase):
self.assertTrue(closed.wait(3), "Client disconnect must close the upstream WebSocket")
login.assert_awaited_once_with("+56000000000")
profile.assert_awaited_once_with("+56000000000", auth_token="fixture-token")
catalog.assert_awaited_once_with(auth_header="Bearer fixture-token", demo_session=ANY, refresh=False)
episode.assert_awaited_once_with("fixture-episode")
def test_proxy_voice_frames_keepalive_and_client_disconnect(self):
closed = threading.Event()
+1 -2
View File
@@ -2,7 +2,7 @@
from fastapi import APIRouter
from vocero_api.api.routes import apipilot, caller, config, conversations, search, sessions
from vocero_api.api.routes import caller, config, conversations, search, sessions
from vocero_api.models.responses import ReasoningResponse
from vocero_api.realtime.proxy import realtime_proxy
from vocero_api.services.reasoning import reasoning
@@ -10,7 +10,6 @@ from vocero_api.services.reasoning import reasoning
router = APIRouter()
router.include_router(config.router)
router.include_router(conversations.router)
router.include_router(apipilot.router)
router.add_api_route("/api/reasoning", reasoning, methods=["POST"], response_model=ReasoningResponse)
router.add_api_websocket_route("/v1/realtime", realtime_proxy)
router.include_router(caller.router)
@@ -1,151 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from vocero_api.api.responses import validated_json_response
from vocero_api.core import config as settings
from vocero_api.core import logging as app_logging
from vocero_api.models import requests as models_requests
from vocero_api.models.responses import ToolCatalogResponse, ToolExecutionResponse
from vocero_api.providers import apipilot as providers_apipilot
router = APIRouter()
@router.get("/api/apipilot/tools", response_model=ToolCatalogResponse)
async def apipilot_list_tools(
request: Request,
include_unauthorized: bool = True,
refresh: bool = False,
) -> JSONResponse:
"""Public proxy for ApiPilot tool discovery (guide §2.1).
Forwards Authorization / x-demo-session headers from the caller to the
upstream backend verbatim and returns the list of `ToolDefinition` records
exactly as documented:
{ "name": "...", "description": "...", "parameters": {...}, "requires_auth": true/false }
A short in-process TTL cache is applied (default 5 min, configurable via
S2S_APIPILOT_TTL_SEC). The client may force a refresh via `?refresh=true`
(e.g. after a user just logged in and previously saw only requires_auth tools)."""
if settings.S2S_MCP_URL:
raise HTTPException(409, "Las herramientas de AquaPortal usan la sesión de conversación.")
if not settings.S2S_APIPILOT_TOOLS_URL:
return validated_json_response(
model=ToolCatalogResponse,
status_code=501,
content={
"success": False,
"data": None,
"error": {
"code": "APIPILOT_NOT_CONFIGURED",
"message": "ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).",
"status_code": 501,
"raw": None,
},
},
)
auth, demo = providers_apipilot._apipilot_parse_authorization(request)
try:
tools = await providers_apipilot._apipilot_fetch_tools(
auth_header=auth,
demo_session=demo,
include_unauthorized=bool(include_unauthorized),
force_refresh=bool(refresh),
)
except HTTPException as exc:
# Normalize to envelope-style payload so downstream code is uniform.
detail = (
exc.detail
if isinstance(exc.detail, dict)
else {
"code": "APIPILOT_ERROR",
"message": str(exc.detail) if exc.detail else "Error",
"status_code": exc.status_code,
"raw": None,
}
)
return validated_json_response(
model=ToolCatalogResponse,
status_code=exc.status_code,
content={"success": False, "data": None, "error": detail},
)
app_logging.logger.info(
"[APIPILOT] /api/apipilot/tools OK tools=%d include_unauthorized=%s refresh=%s",
len(tools),
bool(include_unauthorized),
bool(refresh),
)
return validated_json_response(
{
"success": True,
"data": {
"count": len(tools),
"include_unauthorized": bool(include_unauthorized),
"tools": tools,
},
"error": None,
},
ToolCatalogResponse,
)
@router.post("/api/apipilot/execute", response_model=ToolExecutionResponse)
async def apipilot_execute(request: Request, body: models_requests.ApipilotExecuteRequest) -> JSONResponse:
"""Public proxy for ApiPilot /execute (guide §2.2).
Accepts `{name, arguments}`, runs the mandatory PRE-CHECK flow (fresh tool
discovery, `requires_auth` gate, strict JSON-Schema argument validation),
forwards the request to the upstream backend, and returns the envelope
`{success, data, error}`.
Show only `error.message` to the end user. `error.code`, `error.status_code`
and `error.raw` are for internal auditing (guide §2.2, recommended UI errors)."""
if settings.S2S_MCP_URL:
raise HTTPException(409, "Las herramientas de AquaPortal usan la sesión de conversación.")
if not settings.S2S_APIPILOT_TOOLS_URL:
return validated_json_response(
model=ToolExecutionResponse,
status_code=501,
content={
"success": False,
"data": None,
"error": {
"code": "APIPILOT_NOT_CONFIGURED",
"message": "ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).",
"status_code": 501,
"raw": None,
},
},
)
auth, demo = providers_apipilot._apipilot_parse_authorization(request)
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=API_ENDPOINT_EXECUTE name=%r argsKeys=%s authReceived=%s",
(body.name or ""),
sorted(list((body.arguments or {}).keys())) if isinstance(body.arguments, dict) else [],
("Y" if auth or demo else "none"),
)
envelope = await providers_apipilot._apipilot_execute_tool(
auth_header=auth,
demo_session=demo,
name=body.name,
arguments=body.arguments if isinstance(body.arguments, dict) else {},
)
status_code = 200
if (not envelope.get("success")) and isinstance(envelope.get("error"), dict):
code = envelope["error"].get("status_code")
try:
code_int = int(code)
if 400 <= code_int < 599:
status_code = code_int
except Exception:
pass
app_logging.logger.info(
"[APIPILOT] /api/apipilot/execute name=%s success=%s status=%s",
body.name,
envelope.get("success"),
status_code,
)
return validated_json_response(envelope, ToolExecutionResponse, status_code=status_code)
+3 -10
View File
@@ -21,12 +21,7 @@ async def config(request: Request) -> JSONResponse:
"""Client bootstrap: whether web search is available, whether the deploy runs
behind a load balancer (so the browser uses the /api/session proxy),
and whether the user may instead set a direct
s2s server URL. The LB address itself is intentionally NOT included.
2026-09-24: new top-level `apipilot` flag. When True the deploy exposes the
new `/api/apipilot/tools` and `/api/apipilot/execute` proxy endpoints (see
docs/client/tool-calling.md) that the browser may call directly instead of
hardcoding upstream tool URLs / names."""
s2s server URL. The LB address itself is intentionally NOT included."""
app_logging.logger.debug(
"[INTERNAL] /api/config requested client=%s", request.client.host if request.client else "?"
)
@@ -42,7 +37,6 @@ async def config(request: Request) -> JSONResponse:
"debugPrompt": settings.S2S_DEBUG_PROMPT,
"startSessionMode": settings.S2S_START_SESSION_MODE,
"theme": settings.S2S_THEME,
"apipilot": bool(settings.S2S_APIPILOT_TOOLS_URL),
}
if settings.S2S_MCP_URL:
if settings.S2S_REASONING_TYPE == "PROXY" and (
@@ -50,13 +44,12 @@ async def config(request: Request) -> JSONResponse:
):
raise HTTPException(503, "MCP en modo PROXY requiere la conexión de voz a través de Vocero.")
out["mcp"] = True
out["apipilot"] = False
app_logging.logger.debug(
"[INTERNAL] /api/config returned promptLen=%d reasoning=%s startSessionMode=%s theme=%s apipilot=%s",
"[INTERNAL] /api/config returned promptLen=%d reasoning=%s startSessionMode=%s theme=%s mcp=%s",
len(primary_prompt),
settings.S2S_REASONING_TYPE,
settings.S2S_START_SESSION_MODE or "-",
settings.S2S_THEME or "-",
out["apipilot"],
bool(settings.S2S_MCP_URL),
)
return validated_json_response(out, ConfigResponse)
-44
View File
@@ -137,50 +137,6 @@ OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
OPENAI_REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-4.1-mini").strip()
OPENAI_ORGANIZATION = os.environ.get("OPENAI_ORGANIZATION", "").strip()
OPENAI_PROJECT = os.environ.get("OPENAI_PROJECT", "").strip()
# ──────────────────────────────────────────────────────────────────────────────
# APIPILOT — Backend AquaPortal dynamic-tool bridge (docs/client/tool-calling.md)
# ──────────────────────────────────────────────────────────────────────────────
# Base URL for ApiPilot dynamic tool discovery and execution. When configured,
# the proxy sends GET <url>/tools with include_unauthorized=true (guide §2.1)
# to discover tools, and POST <url>/execute (§2.2) to invoke one. The trailing
# slash is optional; the proxy normalizes it with rstrip("/") below.
#
# Example: S2S_APIPILOT_TOOLS_URL=https://aquachile.azurianlab.com/api/v1/apipilot
S2S_APIPILOT_TOOLS_URL = os.environ.get("S2S_APIPILOT_TOOLS_URL", "").strip().rstrip("/")
# Explicit execution URL override (guide §2.2). When set, it takes precedence
# over S2S_APIPILOT_TOOLS_URL for POST execution calls. Remove trailing slashes
# and append "/execute" only if the URL does not already end with that suffix.
# Example 1 (base): S2S_APIPILOT_TOOLS_EXECUTE_URL=https://aquachile.azurianlab.com/api/v1/apipilot
# → POST https://aquachile.azurianlab.com/api/v1/apipilot/execute
# Example 2: S2S_APIPILOT_TOOLS_EXECUTE_URL=https://aquachile.azurianlab.com/api/v1/apipilot/tools
# → POST https://aquachile.azurianlab.com/api/v1/apipilot/tools/execute
S2S_APIPILOT_TOOLS_EXECUTE_URL = os.environ.get("S2S_APIPILOT_TOOLS_EXECUTE_URL", "").strip().rstrip("/")
# Optional fixed bearer token, used only when the caller supplies no session
# token (Authorization / x-demo-session). Useful for staging service tokens.
S2S_APIPILOT_SERVICE_TOKEN = os.environ.get("S2S_APIPILOT_SERVICE_TOKEN", "").strip()
# 2026-09-25 SUPER API KEY (TEMPORARY AQUAPORTAL AUTHENTICATION BYPASS).
# When set, send all three header forms for maximum compatibility:
# 1) X-API-Key: <key>
# 2) Authorization: Bearer <key>
# 3) x-demo-session: <key>
# Highest priority; completely disables the unnecessary login_by_phone call.
# Clear the value to restore standard login.
S2S_APIPILOT_API_KEY = os.environ.get("S2S_APIPILOT_API_KEY", "").strip().strip("\"'")
# Tool discovery cache TTL in seconds. The guide recommends a five-minute
# client cache; the server uses the same default: 300 seconds.
try:
_APIPILOT_TTL_RAW = int((os.environ.get("S2S_APIPILOT_TTL_SEC", "") or "300").strip())
except Exception:
_APIPILOT_TTL_RAW = 300
APIPILOT_CACHE_TTL_SEC = max(15, min(3600, _APIPILOT_TTL_RAW))
del _APIPILOT_TTL_RAW
# HTTP timeout for ApiPilot discovery and execution calls, in seconds.
try:
_APIPILOT_TO_RAW = float((os.environ.get("S2S_APIPILOT_TIMEOUT_SEC", "") or "15.0").strip())
except Exception:
_APIPILOT_TO_RAW = 15.0
APIPILOT_HTTP_TIMEOUT = max(1.0, min(120.0, _APIPILOT_TO_RAW))
del _APIPILOT_TO_RAW
# Speech-to-speech load balancer URL. When set, the browser POSTs /api/session
+1 -36
View File
@@ -3,23 +3,8 @@ from __future__ import annotations
from vocero_api.core import config as settings
from vocero_api.core import logging as app_logging
# Resolve the execution URL for the startup banner using the same normalization
# as _apipilot_resolve_execute_url.
def _resolve_exec_url_for_startup_banner() -> str:
base = (settings.S2S_APIPILOT_TOOLS_EXECUTE_URL or settings.S2S_APIPILOT_TOOLS_URL).strip().rstrip("/")
if not base:
return ""
if base.lower().endswith("/execute"):
return base
return f"{base}/execute"
_STARTUP_APIPILOT_EXEC_URL = _resolve_exec_url_for_startup_banner()
del _resolve_exec_url_for_startup_banner
app_logging.logger.info(
"[STARTUP] Other externals — reasoning=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s injectReports=%s reportsHintMax=%d reportsInjectMode=%s injectToday=%s summaryApi=%s summaryText=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s theme=%s apipilotToolsUrl=%s apipilotExecuteUrlOverride=%s apipilotServiceToken=%s apipilotTtl=%ds apipilotTimeout=%.1fs apipilotReasoningPass2=%s apipilotApiKeyBypass=%s",
"[STARTUP] Other externals — reasoning=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s injectReports=%s reportsHintMax=%d reportsInjectMode=%s injectToday=%s summaryApi=%s summaryText=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s theme=%s",
settings.S2S_REASONING_TYPE,
bool(settings.SERPER_KEY),
bool(settings.LOAD_BALANCER_URL),
@@ -38,27 +23,7 @@ app_logging.logger.info(
settings.S2S_DEBUG_PROMPT,
settings.S2S_START_SESSION_MODE or "-",
settings.S2S_THEME or "-",
settings.S2S_APIPILOT_TOOLS_URL or "-",
(settings.S2S_APIPILOT_TOOLS_EXECUTE_URL + f"(resolved:{_STARTUP_APIPILOT_EXEC_URL})")
if settings.S2S_APIPILOT_TOOLS_EXECUTE_URL
else "-",
"Y" if bool(settings.S2S_APIPILOT_SERVICE_TOKEN) else "-",
settings.APIPILOT_CACHE_TTL_SEC,
settings.APIPILOT_HTTP_TIMEOUT,
"Y" if (bool(settings.S2S_APIPILOT_TOOLS_URL) or bool(settings.S2S_APIPILOT_TOOLS_EXECUTE_URL)) else "-",
(
"YES head="
+ (
settings.S2S_APIPILOT_API_KEY[:6] + "..."
if len(settings.S2S_APIPILOT_API_KEY) > 6
else settings.S2S_APIPILOT_API_KEY
)
+ " (3 FORMS!)"
)
if settings.S2S_APIPILOT_API_KEY
else "-",
)
del _STARTUP_APIPILOT_EXEC_URL
if settings.S2S_MCP_URL:
app_logging.logger.info(
-7
View File
@@ -29,13 +29,6 @@ class ReasoningRequest(BaseModel):
episode_id: str | None = None
class ApipilotExecuteRequest(BaseModel):
"""Body for the ApiPilot execute proxy endpoint. Mirrors §2.2 request shape."""
name: str
arguments: dict = {}
class QueueEndPayload(BaseModel):
"""A view of a decoded beacon object, preserving the legacy loose value.
-35
View File
@@ -27,7 +27,6 @@ class ConfigResponse(OpenResponse):
debugPrompt: bool
startSessionMode: str
theme: str
apipilot: bool
class SearchResult(OpenResponse):
@@ -62,42 +61,8 @@ class ReportsResponse(OpenResponse):
note: str | None = None
class ToolInvocation(OpenResponse):
name: str
success: bool
result_length: int
class ReasoningResponse(OpenResponse):
answer: str
usage_pass2: Any = None
apipilot_invoked: ToolInvocation | None = None
class ToolDefinition(OpenResponse):
name: str
description: str
parameters: dict[str, Any]
requires_auth: bool
class ToolCatalog(OpenResponse):
count: int
include_unauthorized: bool
tools: list[ToolDefinition]
class ToolCatalogResponse(OpenResponse):
success: bool
data: ToolCatalog | None
error: Any
class ToolExecutionResponse(OpenResponse):
success: bool
# The upstream may omit these fields or return any JSON value for them.
data: Any = None
error: Any = None
class AllocationResponse(OpenResponse):
-597
View File
@@ -1,597 +0,0 @@
from __future__ import annotations
import asyncio
import json
from collections.abc import Callable
from time import monotonic
from typing import Any, cast
import httpx
from fastapi import HTTPException, Request
from vocero_api.core import config as settings
from vocero_api.core import logging as app_logging
# In-process cache and asynchronous lock: one caller refreshes while others
# read the stale entry. `_APIPILOT_CACHE[auth_fingerprint] = (fetched_at_monotonic, tools_list)`.
_APIPILOT_CACHE: dict[str, tuple[float, list[dict]]] = {}
_APIPILOT_CACHE_LOCK = asyncio.Lock()
# Monotonic time helper for duration measurements unaffected by NTP drift.
_monotonic_now: Callable[[], float] = monotonic
def _apipilot_auth_fingerprint(auth_header: str | None, demo_session: str | None) -> str:
"""Return a short string used as cache key scoped to the caller identity.
We don't want to share cached tool-lists across users with different auth
tokens (requires_auth flags can be token-dependent via /tools?include_unauthorized=true
because the back-end may still attach per-role metadata)."""
parts: list[str] = []
if auth_header:
# Don't cache the raw bearer. SHA-256 truncated 12 chars = safe fingerprint.
import hashlib
parts.append("h:" + hashlib.sha256(auth_header.encode("utf-8")).hexdigest()[:12])
if demo_session:
parts.append("d:" + demo_session)
return "|".join(parts) if parts else "anon"
def _apipilot_parse_authorization(request: Request | None) -> tuple[str | None, str | None]:
"""Extract (Authorization header value, x-demo-session header value) from a FastAPI request.
Returns a tuple of two optional strings. Both are forwarded to the upstream
ApiPilot backend verbatim when non-empty (see docs/client/tool-calling.md).
"""
if request is None:
return None, None
auth = None
demo = None
try:
raw_auth = request.headers.get("authorization") or request.headers.get("Authorization")
if isinstance(raw_auth, str) and raw_auth.strip():
auth = raw_auth.strip()
raw_demo = request.headers.get("x-demo-session") or request.headers.get("X-Demo-Session")
if isinstance(raw_demo, str) and raw_demo.strip():
demo = raw_demo.strip()
except Exception:
pass
return auth, demo
def _apipilot_resolve_execute_url() -> str:
"""Resolve the final POST URL for ApiPilot tool execution.
Priority (guide §2.2 and explicit environment override):
1. When S2S_APIPILOT_TOOLS_EXECUTE_URL is set, append "/execute" if needed.
2. Fallback S2S_APIPILOT_TOOLS_URL + "/execute".
If neither URL is configured, the caller (usually _apipilot_execute_tool)
must return an APIPILOT_NOT_CONFIGURED error envelope before calling this.
"""
base = settings.S2S_APIPILOT_TOOLS_EXECUTE_URL or settings.S2S_APIPILOT_TOOLS_URL
base = base.strip().rstrip("/")
if not base:
return ""
if base.lower().endswith("/execute"):
return base
return f"{base}/execute"
def _apipilot_strip_tool_for_llm(tool_def: dict) -> dict:
"""Return a tool definition that is safe for LLM function-calling providers.
Guide §4 forbids passing raw /tools output directly to the model because
requires_auth is outside the function-calling standard and some providers
reject it. Project the native `ToolDefinition` into the OpenAI-compatible
`{type: "function", function: {name, description, parameters}}`.
"""
if not isinstance(tool_def, dict):
return {}
name = tool_def.get("name") if isinstance(tool_def.get("name"), str) else ""
description = tool_def.get("description") if isinstance(tool_def.get("description"), str) else ""
parameters = tool_def.get("parameters")
if not isinstance(parameters, dict):
parameters = {"type": "object", "properties": {}, "required": []}
out: dict = {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": parameters,
},
}
return out
async def _apipilot_fetch_tools(
*,
auth_header: str | None,
demo_session: str | None,
include_unauthorized: bool = True,
force_refresh: bool = False,
refresh: bool | None = None,
) -> list[dict]:
"""Fetch ApiPilot tool definitions with a short in-process TTL cache.
Implements §2.1 (GET /tools?include_unauthorized=true). The cache key is
scoped by caller auth fingerprint (see `_apipilot_auth_fingerprint`) so
per-token variations in `requires_auth` or list length don't leak between
identities.
Returns a list of tool definitions (parsed JSON, may be empty). Raises
`HTTPException` only when `S2S_APIPILOT_TOOLS_URL` is set but the backend
replied with a non-2xx / non-JSON response. When env is empty → returns
`[]` silently (passthrough).
"""
# 2026-09-25 compatibility alias: WS connect uses `refresh`; when present,
# apply it to force_refresh.
if refresh is not None:
force_refresh = bool(refresh) or bool(force_refresh)
if not settings.S2S_APIPILOT_TOOLS_URL:
return []
import time as _time_mod
# 2026-09-25 fingerprint consistency: determine effective auth/demo values
# first (SUPER API KEY takes precedence), then calculate the cache fingerprint.
# An anonymous fingerprint with APIKEY headers would mismatch the cache and
# demo session, causing Aquachile user_type=null and a 403 response.
if settings.S2S_APIPILOT_API_KEY:
final_auth: str | None = "BYPASS-API-KEY"
_demo_used: str | None = settings.S2S_APIPILOT_API_KEY
_effective_auth_for_fp = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
_effective_demo_for_fp = settings.S2S_APIPILOT_API_KEY
else:
final_auth = auth_header or (
f"Bearer {settings.S2S_APIPILOT_SERVICE_TOKEN}" if settings.S2S_APIPILOT_SERVICE_TOKEN else None
)
_demo_used = demo_session
_effective_auth_for_fp = (
final_auth if isinstance(final_auth, str) and final_auth.strip() else (auth_header or "")
)
_effective_demo_for_fp = _demo_used if isinstance(_demo_used, str) and _demo_used.strip() else ""
fp = _apipilot_auth_fingerprint(_effective_auth_for_fp, _effective_demo_for_fp)
now = _time_mod.monotonic()
cached = _APIPILOT_CACHE.get(fp)
if (not force_refresh) and cached and (now - cached[0]) < settings.APIPILOT_CACHE_TTL_SEC:
app_logging.logger.debug(
"[APIPILOT-DISCOVERY] cache HIT ttl=%.1fs fp=%s tools=%d",
settings.APIPILOT_CACHE_TTL_SEC - (now - cached[0]),
fp,
len(cached[1]),
)
return cached[1]
# Prefer to serve stale (if any) rather than block many callers.
stale_tools = cached[1] if cached else None
async with _APIPILOT_CACHE_LOCK:
# Double-check after acquiring lock (another coroutine may have refreshed).
now2 = _time_mod.monotonic()
recheck = _APIPILOT_CACHE.get(fp)
if (not force_refresh) and recheck and (now2 - recheck[0]) < settings.APIPILOT_CACHE_TTL_SEC:
return recheck[1]
url = f"{settings.S2S_APIPILOT_TOOLS_URL}/tools"
params = {"include_unauthorized": "true" if include_unauthorized else "false"}
headers = {"accept": "application/json"}
# 2026-09-25 SUPER API KEY overrides other authentication and sends all
# three header forms simultaneously for maximum compatibility.
if settings.S2S_APIPILOT_API_KEY:
headers["X-API-Key"] = settings.S2S_APIPILOT_API_KEY
headers["Authorization"] = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
headers["x-demo-session"] = settings.S2S_APIPILOT_API_KEY
else:
if final_auth:
headers["Authorization"] = final_auth
if _demo_used:
headers["x-demo-session"] = _demo_used
app_logging.logger.info(
"[APIPILOT-DISCOVERY] → GET %s?include_unauthorized=%s fp=%s auth=%s demo=%s",
url,
params["include_unauthorized"],
fp,
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if final_auth else "-")),
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if _demo_used else "-")),
)
try:
async with httpx.AsyncClient(timeout=settings.APIPILOT_HTTP_TIMEOUT, follow_redirects=True) as http:
resp = await http.get(url, params=params, headers=headers)
except Exception as exc:
app_logging.logger.error("[APIPILOT-DISCOVERY] ✗ network error: %s", exc)
if stale_tools is not None:
app_logging.logger.warning(
"[APIPILOT-DISCOVERY] using STALE cached fallback tools=%d", len(stale_tools)
)
return stale_tools
raise HTTPException( # noqa: B904
status_code=502,
detail={
"code": "APIPILOT_NETWORK_ERROR",
"message": f"Error al contactar ApiPilot tools backend: {exc}",
},
)
if resp.status_code >= 300:
app_logging.logger.error(
"[APIPILOT-DISCOVERY] ✗ backend HTTP %s body=%s", resp.status_code, resp.text[:400]
)
if stale_tools is not None:
app_logging.logger.warning(
"[APIPILOT-DISCOVERY] using STALE cached fallback tools=%d", len(stale_tools)
)
return stale_tools
raise HTTPException(
status_code=502,
detail={
"code": "APIPILOT_BAD_UPSTREAM",
"message": f"Backend ApiPilot tools devolvió HTTP {resp.status_code}",
"status_code": resp.status_code,
},
)
try:
payload = resp.json()
except Exception:
app_logging.logger.error("[APIPILOT-DISCOVERY] ✗ non-JSON response: %s", resp.text[:500])
if stale_tools is not None:
return stale_tools
raise HTTPException( # noqa: B904
status_code=502,
detail={"code": "APIPILOT_BAD_JSON", "message": "La respuesta de ApiPilot tools no es JSON válido."},
)
# Backend might return: list OR {"result": [...]} OR {"tools": [...]} OR
# {"data": {"tools": [...]}}. Unwrap generic wrapper-style payloads.
tools: list[dict] = []
if isinstance(payload, list):
tools = [t for t in payload if isinstance(t, dict)]
elif isinstance(payload, dict):
for key in ("tools", "result", "data"):
cand = payload.get(key)
if isinstance(cand, list):
tools = [t for t in cand if isinstance(t, dict)]
break
if isinstance(cand, dict):
sub = cand.get("tools") if isinstance(cand.get("tools"), list) else cand.get("result")
if isinstance(sub, list):
tools = [t for t in sub if isinstance(t, dict)]
break
# Validate minimal ToolDefinition contract §2.1: name + description + parameters.
sanitized: list[dict] = []
for raw in tools:
name = raw.get("name")
if not isinstance(name, str) or not name.strip():
continue
description = raw.get("description") if isinstance(raw.get("description"), str) else ""
parameters = raw.get("parameters")
if not isinstance(parameters, dict):
parameters = {"type": "object", "properties": {}, "required": []}
requires_auth = raw.get("requires_auth", False)
if not isinstance(requires_auth, bool):
# Some backends return 0/1; normalize to bool as required.
try:
requires_auth = bool(int(requires_auth))
except Exception:
requires_auth = False
tool_def = {
"name": name.strip(),
"description": description,
"parameters": parameters,
"requires_auth": requires_auth,
}
sanitized.append(tool_def)
_APIPILOT_CACHE[fp] = (now2, sanitized)
app_logging.logger.info(
"[APIPILOT-DISCOVERY] ← OK tools=%s fp=%s status=%s upstreamFmt=%s cachedTools=%d",
len(sanitized),
fp,
resp.status_code,
"list" if isinstance(payload, list) else "object",
len(sanitized),
)
if settings.S2S_DEBUG_PROMPT and sanitized:
app_logging.logger.debug(
"[APIPILOT-DISCOVERY] raw first tool preview: %s",
json.dumps(sanitized[0], ensure_ascii=False)[:400],
)
return sanitized
def _apipilot_validate_arguments(arguments: dict | None, tool_def: dict) -> tuple[dict, list[str]]:
"""Validate / execute arguments strictly against the tool's JSON-Schema parameters.
Implements §2.2 PRE-CHECK #3:
• Remove unknown keys only when parameters.properties is nonempty.
• AquaPortal tool.get_user_briefing has parameters.properties={} and
required=[]: no explicit schema, so arguments remain flexible. The
backend resolves phone from the token; preserve supplied arguments.
• Validate required parameters and return errors for missing values.
• Never invent parameters or add keys.
Returns `(filtered_args, list_of_validation_errors_in_spanish)`.
If the returned errors list is non-empty, MUST block the execution (see
the `_apipilot_execute_tool` caller).
"""
errors: list[str] = []
schema = cast(dict[str, Any], tool_def.get("parameters")) if isinstance(tool_def.get("parameters"), dict) else {}
properties = cast(dict[str, Any], schema.get("properties")) if isinstance(schema.get("properties"), dict) else {}
required = cast(list[Any], schema.get("required")) if isinstance(schema.get("required"), list) else []
required = [x for x in required if isinstance(x, str)]
args = arguments if isinstance(arguments, dict) else {}
# 2026-09-25 empty-schema flexibility: preserve all arguments when no
# properties are explicitly declared, as with AquaPortal get_user_briefing.
schema_is_open: bool = (not isinstance(properties, dict)) or len(properties) == 0
requires_auth_flag = bool(tool_def.get("requires_auth"))
# 2026-09-25 auth-only tools: requires_auth=true, empty properties and
# required=[], with a valid-session description, resolve user data from
# the bearer JWT. Send arguments={} to avoid conflicts between a phone
# argument and the token's user, which would produce a 403 response.
auth_only_tool = False
if schema_is_open and requires_auth_flag and len(required) == 0:
_d = tool_def.get("description") if isinstance(tool_def.get("description"), str) else ""
if (not _d) or "valid session token" in _d.lower() or "authenticated" in _d.lower():
auth_only_tool = True
if auth_only_tool:
cleaned = {}
app_logging.logger.info(
"[APIPILOT-VALIDATE] tool=%s requires only an authenticated session → arguments forced to {} (avoid phone-argument/user-token conflicts)",
tool_def.get("name") or "?",
)
elif schema_is_open:
cleaned = dict(args)
app_logging.logger.info(
"[APIPILOT-VALIDATE] tool=%s open schema (empty parameters.properties) → preserve all arguments N=%d keys=%s",
tool_def.get("name") or "?",
len(cleaned),
sorted(list(cleaned.keys())),
)
else:
cleaned = {}
for key in args.keys():
if key in properties:
cleaned[key] = args[key]
else:
app_logging.logger.warning(
"[APIPILOT-VALIDATE] tool=%s removing unknown arg=%s",
tool_def.get("name") or "?",
key,
)
# Check required fields only for a closed schema; open required=[] is vacuous.
if not schema_is_open:
for req in required:
if req not in cleaned:
errors.append(f"Falta el parámetro requerido: {req}")
return cleaned, errors
async def _apipilot_execute_tool(
*,
auth_header: str | None,
demo_session: str | None,
name: str,
arguments: dict | None,
) -> dict:
"""Run a single ApiPilot tool via POST /execute.
Implements the full PRE-CHECK flow (§2.2) BEFORE sending the HTTP
request:
1. Fresh discovery (TTL cache; force_refresh=True if a tool is missing).
2. requires_auth: return HTTP 401 when authentication is required but absent.
3. Strict JSON-Schema validation: filter unknown parameters and check required.
Always return the standard `{success, data, error}` envelope (guide §2.2).
Pre-check failures populate error.{code,message,status_code}, with success=false.
"""
name_clean = (name or "").strip() if isinstance(name, str) else ""
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=EXECUTE_ENTRYPOINT name=%r argsKeys=%s authHead=%.20s demo=%.20s",
name_clean,
sorted(list((arguments or {}).keys())) if isinstance(arguments, dict) else [],
str(auth_header or "")[:20],
str(demo_session or "")[:20],
)
if not settings.S2S_APIPILOT_TOOLS_URL:
return {
"success": False,
"data": None,
"error": {
"code": "APIPILOT_NOT_CONFIGURED",
"message": "ApiPilot no está configurado en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).",
"status_code": 501,
"raw": None,
},
}
if not name_clean:
return {
"success": False,
"data": None,
"error": {
"code": "TOOL_NAME_REQUIRED",
"message": "Falta el campo obligatorio: name (nombre de la herramienta).",
"status_code": 400,
"raw": None,
},
}
tools = await _apipilot_fetch_tools(auth_header=auth_header, demo_session=demo_session, force_refresh=False)
tool_def = next((t for t in tools if t.get("name") == name_clean), None)
if tool_def is None:
# Unknown tool: refresh discovery and retry once.
app_logging.logger.info("[APIPILOT-EXEC] tool=%s not found → forced re-discovery", name_clean)
tools = await _apipilot_fetch_tools(auth_header=auth_header, demo_session=demo_session, force_refresh=True)
tool_def = next((t for t in tools if t.get("name") == name_clean), None)
if tool_def is None:
return {
"success": False,
"data": None,
"error": {
"code": "TOOL_NOT_FOUND",
"message": (
f"Herramienta «{name_clean}» no encontrada en el catálogo ApiPilot. "
"Actualice la lista de herramientas y vuelva a intentarlo."
),
"status_code": 404,
"raw": None,
},
}
# ── PRE-CHECK 2: requires_auth ────────────────────────────────────────────
requires_auth = bool(tool_def.get("requires_auth"))
has_token = bool(auth_header) or bool(demo_session) or bool(settings.S2S_APIPILOT_SERVICE_TOKEN)
if requires_auth and not has_token:
app_logging.logger.warning("[APIPILOT-EXEC] tool=%s requires_auth=true blocked (no token)", name_clean)
return {
"success": False,
"data": None,
"error": {
"code": "AUTH_REQUIRED",
"message": (
f"La herramienta «{name_clean}» requiere autenticación. "
"Inicie sesión o proporcione un token para continuar."
),
"status_code": 401,
"raw": None,
},
}
# Pre-check 3: strict JSON-Schema argument validation.
cleaned_args, errors = _apipilot_validate_arguments(arguments, tool_def)
if errors:
return {
"success": False,
"data": None,
"error": {
"code": "INVALID_ARGUMENTS",
"message": " / ".join(errors),
"status_code": 400,
"raw": {
"removed": sorted(list(set(arguments) - set(cleaned_args))) if isinstance(arguments, dict) else []
},
},
}
# ── HTTP: POST /execute ───────────────────────────────────────────────────
url = _apipilot_resolve_execute_url()
if not url:
return {
"success": False,
"data": None,
"error": {
"code": "APIPILOT_NOT_CONFIGURED",
"message": "ApiPilot no está configurado en este despliegue (S2S_APIPILOT_TOOLS_URL y S2S_APIPILOT_TOOLS_EXECUTE_URL están vacíos).",
"status_code": 501,
"raw": None,
},
}
headers = {"Content-Type": "application/json", "accept": "application/json,text/plain,*/*"}
# 2026-09-25 SUPER API KEY (highest priority): bypass AquaPortal authentication.
# Send all three header forms simultaneously for maximum compatibility:
# - X-API-Key: <key>
# - Authorization: Bearer <key>
# - x-demo-session: <key>
# Ignore service tokens and login_by_phone when the API KEY is set.
if settings.S2S_APIPILOT_API_KEY:
headers["X-API-Key"] = settings.S2S_APIPILOT_API_KEY
headers["Authorization"] = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
headers["x-demo-session"] = settings.S2S_APIPILOT_API_KEY
final_auth: str | None = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
else:
# Authentication priority: service-token override, then caller auth_header
# from phone login/discovery/pass-2 fallback, then no authentication.
if settings.S2S_APIPILOT_SERVICE_TOKEN:
_svc = settings.S2S_APIPILOT_SERVICE_TOKEN.strip()
final_auth = (f"Bearer {_svc}" if (not _svc.lower().startswith("bearer ")) else _svc) if _svc else None
else:
final_auth = auth_header if isinstance(auth_header, str) and auth_header.strip() else None
if final_auth:
headers["Authorization"] = final_auth
if demo_session:
headers["x-demo-session"] = demo_session
payload = {"name": name_clean, "arguments": cleaned_args}
removed = sorted(list(set(arguments) - set(cleaned_args))) if isinstance(arguments, dict) else []
app_logging.logger.info(
"[APIPILOT-EXEC] → POST %s name=%s argsKeys=%s removedKeys=%s auth=%s demo=%s",
url,
name_clean,
sorted(cleaned_args.keys()),
removed,
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if final_auth else "-")),
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if demo_session else "-")),
)
try:
async with httpx.AsyncClient(timeout=settings.APIPILOT_HTTP_TIMEOUT, follow_redirects=True) as http:
resp = await http.post(url, json=payload, headers=headers)
except Exception as exc:
app_logging.logger.error("[APIPILOT-EXEC] ✗ network error: %s", exc)
return {
"success": False,
"data": None,
"error": {
"code": "EXECUTION_ERROR",
"message": f"Error de red al ejecutar «{name_clean}»: {exc}",
"status_code": 502,
"raw": None,
},
}
# Parse a standard envelope; construct a matching error for non-JSON/5xx responses.
raw_text = resp.text
# Detailed diagnostics are always enabled for HTTP >= 400: request payload,
# unmasked demo session, response status and bounded raw response body.
if resp.status_code >= 400:
try:
payload_dump = __import__("json").dumps(payload, ensure_ascii=False)
except Exception: # noqa: BLE001
payload_dump = str(payload)
app_logging.logger.warning(
"[APIPILOT-EXEC][DIAGNOSTIC 4xx] HTTP=%d url=%s name=%s demoSession=%.60s AUTH=%s requestPAYLOAD=%s |RAW RESPONSE BODY (max 1800 chars)=|%s|",
resp.status_code,
url,
name_clean,
(demo_session if isinstance(demo_session, str) else "?"),
("YES len=" + str(len(final_auth)) if isinstance(final_auth, str) and final_auth.strip() else "-"),
payload_dump,
(raw_text or "")[:1800],
)
try:
parsed = resp.json()
except Exception:
app_logging.logger.error("[APIPILOT-EXEC] ✗ non-JSON body HTTP %s: %s", resp.status_code, raw_text[:500])
return {
"success": False,
"data": None,
"error": {
"code": "EXECUTION_ERROR",
"message": f"La respuesta de ejecución «{name_clean}» no es JSON válido (HTTP {resp.status_code}).",
"status_code": resp.status_code,
"raw": raw_text[:2000] if raw_text else None,
},
}
# Return standard {success,data,error} envelopes unchanged.
if isinstance(parsed, dict) and "success" in parsed and isinstance(parsed.get("success"), bool):
envelope_ok = parsed["success"] and parsed.get("error") in (None, {})
app_logging.logger.info(
"[APIPILOT-EXEC] ← %s name=%s success=%s http=%s dataKeys=%s",
"OK" if envelope_ok else "FAIL",
name_clean,
bool(parsed.get("success")),
resp.status_code,
sorted(list(cast(dict, parsed.get("data")).keys())) if isinstance(parsed.get("data"), dict) else "-",
)
if (not envelope_ok) and isinstance(parsed.get("error"), dict):
err = parsed["error"]
app_logging.logger.warning(
"[APIPILOT-EXEC] ERROR: code=%s status=%s msg=%s",
err.get("code"),
err.get("status_code"),
(str(err.get("message"))[:200] if err.get("message") is not None else "-"),
)
return parsed
# Wrap raw backend data in the required envelope (guide §2.2) for stable
# downstream behavior when an upstream response omits the wrapper.
app_logging.logger.warning(
"[APIPILOT-EXEC] backend omitted standard envelope → wrapping response http=%s", resp.status_code
)
return {
"success": 200 <= resp.status_code < 300,
"data": parsed if (200 <= resp.status_code < 300) else None,
"error": None
if (200 <= resp.status_code < 300)
else {
"code": "EXECUTION_ERROR",
"message": (f"Ejecución «{name_clean}» devolvió HTTP {resp.status_code} sin envelope estándar."),
"status_code": resp.status_code,
"raw": (raw_text[:2000] if raw_text else None),
},
}
-5
View File
@@ -6,7 +6,6 @@ from typing import Any, cast
from vocero_api.core import logging as app_logging
from vocero_api.services import context as services_context
from vocero_api.services import tool_context as services_tool_context
async def _report_server_debug(hypothesis_id: str, location: str, msg: str, data: dict | None = None) -> None:
@@ -324,10 +323,6 @@ def _sanitize_realtime_event_raw(raw_message: str, session_ctx: dict[str, Any] |
nonlocal changed
if isinstance(v, str) and v.strip():
cleaned = _sanitize_agent_output(v, session_ctx)
# Layer 8 removes internal ApiPilot CALL markers before UI/TTS.
# The pass-2 accumulator retains the original transcript so
# execution can still be triggered by its marker.
cleaned = services_tool_context._sanitize_text_strip_apipilot_markers(cleaned)
if cleaned != v:
changed = True
return cleaned
+5 -785
View File
@@ -13,12 +13,10 @@ from websockets.typing import Subprotocol
from vocero_api.core import config as settings
from vocero_api.core import logging as app_logging
from vocero_api.core import urls as core_urls
from vocero_api.providers import apipilot as providers_apipilot
from vocero_api.providers import caller as providers_caller
from vocero_api.realtime import events as realtime_events
from vocero_api.realtime.mcp_proxy import realtime_mcp_proxy
from vocero_api.services import context as services_context
from vocero_api.services import tool_context as services_tool_context
async def realtime_proxy(ws: WebSocket) -> None:
@@ -46,102 +44,20 @@ async def realtime_proxy(ws: WebSocket) -> None:
injected_profile_context = ""
injected_reports_context = ""
injected_summary_context = ""
injected_apipilot_pass1 = ""
apipilot_tools_pass1: list = []
inject_phone = ""
inject_episode_id = ""
client_query = {}
login_token: str | None = None
login_response_obj: dict | None = None
profile_obj: dict | None = None
needs_apipilot = bool(settings.S2S_APIPILOT_TOOLS_URL) or bool(settings.S2S_APIPILOT_TOOLS_EXECUTE_URL)
try:
client_query = dict(parse_qsl(ws.url.query, keep_blank_values=True))
except Exception: # noqa: BLE001
client_query = {}
# ApiPilot works independently of profile injection. When enabled, extract
# phone/episode query parameters, resolve the bearer for authenticated tools,
# and load the profile for email defaults when its endpoint is configured.
if needs_apipilot:
inject_phone = (client_query.get("phoneNumber") or "").strip() or inject_phone
inject_episode_id = (client_query.get("episode_id") or "").strip() or inject_episode_id
_needs_login_apipilot = bool(settings.S2S_LOGIN_BY_PHONE.strip())
_needs_profile_apipilot = bool(settings.VOCERO_USER_PHONE_AUTH_URL.strip())
# Always log initial WS state to diagnose missing phone, token or login
# configuration that could leave the AquaPortal user anonymous.
_s_init = {
"needs_apipilot": needs_apipilot,
"phoneLen": len(inject_phone or ""),
"phoneHead": (
("+" + inject_phone.lstrip("+")[:3] + "...")
if (inject_phone and len(inject_phone) > 6)
else (inject_phone or "")
),
"loginTokenLen": (len(login_token) if isinstance(login_token, str) and login_token.strip() else 0),
"loginByPhone": ("YES" if _needs_login_apipilot else "NO"),
"profileUrl": ("YES" if _needs_profile_apipilot else "NO"),
"S2S_INJECT_PROFILE": settings.S2S_INJECT_PROFILE,
}
app_logging.logger.info(
"[INJECT-APIPILOT][WS connect] INITIAL STATE: " + ", ".join(f"{k}={v}" for k, v in sorted(_s_init.items())),
)
# The API-key bypass makes login_by_phone unnecessary.
if (
not settings.S2S_APIPILOT_API_KEY
and _needs_login_apipilot
and inject_phone
and (
not isinstance(login_token, str)
or not login_token.strip()
or (isinstance(login_token, str) and 0 < len(login_token.strip()) < 80)
)
):
try:
login_token, _lresp = await providers_caller._login_by_phone(inject_phone)
if isinstance(_lresp, dict) and _lresp:
login_response_obj = _lresp
app_logging.logger.info(
"[INJECT-APIPILOT][WS connect] login_by_phone for authenticated ApiPilot execution phoneLen=%d token=%s",
len(inject_phone or ""),
f"YES len={len(login_token or '')}"
if isinstance(login_token, str) and login_token
else "NONE (403!)",
)
except Exception as _exc_login: # noqa: BLE001
app_logging.logger.info(
"[INJECT-APIPILOT][WS connect] login_by_phone failed (anonymous ApiPilot user, likely 403) phoneLen=%d err=%r",
len(inject_phone or ""),
_exc_login,
)
if _needs_profile_apipilot and inject_phone and (not isinstance(profile_obj, dict) or not profile_obj):
try:
profile_obj = await providers_caller._fetch_user_profile_for_phone(inject_phone, auth_token=login_token)
app_logging.logger.info(
"[INJECT-APIPILOT][WS connect] profile fetch for email defaults phoneLen=%d profile=%s",
len(inject_phone or ""),
("FOUND user=%s" % (((profile_obj or {}).get("user") or {}).get("name") or "?"))
if isinstance(profile_obj, dict)
else "NONE",
)
except Exception as _exc_p: # noqa: BLE001
app_logging.logger.info(
"[INJECT-APIPILOT][WS connect] profile fetch failed phoneLen=%d err=%r",
len(inject_phone or ""),
_exc_p,
)
if not needs_apipilot:
app_logging.logger.info(
"[INJECT-APIPILOT] ⏭️ WS connect: APIPILOT tools injection DISABLED (S2S_APIPILOT_TOOLS_URL='%s' S2S_APIPILOT_TOOLS_EXECUTE_URL='%s'). "
"No ApiPilot fetch or session.update patch in this tunnel.",
settings.S2S_APIPILOT_TOOLS_URL,
settings.S2S_APIPILOT_TOOLS_EXECUTE_URL,
)
if not settings.S2S_INJECT_PROFILE:
app_logging.logger.info(
"[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (S2S_INJECT_PROFILE=false). "
"No profile/episode fetch; tunnel stays pure passthrough for injects EXCEPT apipilot."
"No profile/episode fetch; tunnel stays pure passthrough for context injection."
)
else:
needs_phone_authed = bool(settings.VOCERO_USER_PHONE_AUTH_URL.strip())
@@ -177,7 +93,7 @@ async def realtime_proxy(ws: WebSocket) -> None:
# 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set).
# Obtains a bearer token used on subsequent profile/reports/summary fetches.
if needs_login and inject_phone:
login_token, login_response_obj = await providers_caller._login_by_phone(inject_phone)
login_token, _ = await providers_caller._login_by_phone(inject_phone)
# 0.5) Podcast episode fetch (independent of phone — uses only episode_id from query)
if inject_episode_id:
if settings.S2S_DEBUG_PROMPT:
@@ -259,121 +175,12 @@ async def realtime_proxy(ws: WebSocket) -> None:
app_logging.logger.info(
"[INJECT-SUMMARY] ⏭️ WS connect: latest summary skipped (context empty / upstream returned empty summary / fetch failed)."
)
# 3) Fetch the ApiPilot pass-1 tool list independently of S2S_INJECT_PROFILE,
# even without login/phone. Use the resolved token or anonymous fingerprint,
# matching the reasoning endpoint's server cache.
if needs_apipilot:
apipilot_auth_header = f"Bearer {login_token}" if isinstance(login_token, str) and login_token.strip() else None
# Phone login resolves the AquaPortal JWT for execution. The configured
# S2S_APIPILOT_SERVICE_TOKEN overrides it.
if settings.S2S_APIPILOT_SERVICE_TOKEN:
_svc = settings.S2S_APIPILOT_SERVICE_TOKEN.strip()
if _svc:
apipilot_auth_header = f"Bearer {_svc}" if not _svc.lower().startswith("bearer ") else _svc
demo_session_key = client_query.get("x-demo-session") if isinstance(client_query, dict) else None
if not demo_session_key:
demo_session_key = f"anon-ws-{client_ip}-{id(ws)}"
try:
# Always log discovery, matching reasoning pass 1.
app_logging.logger.info(
"[INJECT-APIPILOT] WS connect: fetching tools (auth=%s demoSession=%.40s...) upstream=%s...",
"yes" if apipilot_auth_header else "anon",
(demo_session_key[:40] if isinstance(demo_session_key, str) else "?"),
settings.S2S_APIPILOT_TOOLS_URL or "<using override execute>",
)
if settings.S2S_DEBUG_PROMPT:
app_logging.logger.info(
"[DEBUG-APIPILOT][WS connect] tools fetch detail toolsURL=%s executeURL=%s overrideExec=%s",
settings.S2S_APIPILOT_TOOLS_URL,
settings.S2S_APIPILOT_TOOLS_EXECUTE_URL,
providers_apipilot._apipilot_resolve_execute_url(),
)
_t0 = providers_apipilot._monotonic_now()
apipilot_tools_pass1 = (
await providers_apipilot._apipilot_fetch_tools(
auth_header=apipilot_auth_header,
demo_session=demo_session_key if isinstance(demo_session_key, str) else None,
refresh=False,
)
or []
)
_el = providers_apipilot._monotonic_now() - _t0
# Supply known phone/episode defaults to pass 1 so the model does
# not leave required arguments empty (observed ARGUMENTS=>>> bug).
_apipilot_defaults_p1: dict[str, str] = {}
if isinstance(inject_phone, str) and inject_phone.strip():
_apipilot_defaults_p1["phone"] = inject_phone.strip()
if isinstance(inject_episode_id, str) and inject_episode_id.strip():
_apipilot_defaults_p1["episode_id"] = inject_episode_id.strip()
# Also supply email when the resolved profile has a valid value.
if isinstance(profile_obj, dict) and profile_obj:
try:
u = profile_obj.get("user") if isinstance(profile_obj.get("user"), dict) else None
if isinstance(u, dict):
email = cast(str, u.get("email")) if isinstance(u.get("email"), str) else ""
if email.strip():
_apipilot_defaults_p1["email"] = email.strip()
except Exception: # noqa: BLE001
pass
session_ctx_apipilot_defaults = dict(_apipilot_defaults_p1)
injected_apipilot_pass1 = (
services_tool_context._resolve_apipilot_reasoning_context_block(
tools_list=apipilot_tools_pass1,
context_defaults=_apipilot_defaults_p1,
)
or ""
)
if injected_apipilot_pass1.strip():
app_logging.logger.info(
"[APIPILOT] REASONING PASS-1 (WS realtime): tool list ready N=%d t=%.0fms auth=%s defaults=%s",
len(apipilot_tools_pass1),
_el,
"Y" if isinstance(apipilot_auth_header, str) else "anon",
list(_apipilot_defaults_p1.keys()),
)
app_logging.logger.info(
"[INJECT-APIPILOT] ✅ WS connect: APIPILOT TOOLS list PASS-1 READY Ntools=%d wrappedLen=%d defaultsPhone=%s -> will patch session.update frames in this tunnel.",
len(apipilot_tools_pass1),
len(injected_apipilot_pass1),
("yes" if _apipilot_defaults_p1.get("phone") else "no"),
)
else:
app_logging.logger.info(
"[INJECT-APIPILOT] ⏭️ WS connect: apipilot fetch returned empty tools list; PASS-1 injection skipped for this tunnel.",
)
except Exception as exc: # noqa: BLE001
app_logging.logger.info(
"[INJECT-APIPILOT] ⛔ WS connect: apipilot tools fetch failed. Skipping PASS-1 injection for tunnel. err=%r",
exc,
)
injected_apipilot_pass1 = ""
apipilot_tools_pass1 = []
session_ctx_apipilot_defaults = {}
# Preserve the exact discovery authentication and demo-session values.
# AquaPortal binds authorized tools to GET /tools' x-demo-session; a new
# ws-pass2 value would appear anonymous and produce a 403 response.
_ws_apipilot_auth_for_execute = apipilot_auth_header
_ws_apipilot_demo_for_execute = demo_session_key if isinstance(demo_session_key, str) else None
# Preserve API-key identity in every propagation variable, matching the
# discovery cache fingerprint and HTTP headers even during pass-2 fallback.
if settings.S2S_APIPILOT_API_KEY:
_ws_apipilot_auth_for_execute = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
_ws_apipilot_demo_for_execute = settings.S2S_APIPILOT_API_KEY
else:
# With ApiPilot unconfigured, keep defaults as an empty dict rather than None.
session_ctx_apipilot_defaults = {}
injected_apipilot_pass1 = ""
apipilot_tools_pass1 = []
_ws_apipilot_auth_for_execute = None
_ws_apipilot_demo_for_execute = None
app_logging.logger.info(
"[INJECT-PROFILE] WS connect FINAL: injectEpisode=%s injectProfile=%s injectReports=%s injectSummary=%s injectApipilotPass1=%s(Ntools=%d) login=%s episodeIdLen=%d phoneLen=%d episodeCtxLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d apipilotCtxLen=%d target=%s",
"[INJECT-PROFILE] WS connect FINAL: injectEpisode=%s injectProfile=%s injectReports=%s injectSummary=%s login=%s episodeIdLen=%d phoneLen=%d episodeCtxLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d target=%s",
bool(injected_episode_context),
bool(injected_profile_context),
bool(injected_reports_context),
bool(injected_summary_context),
bool(injected_apipilot_pass1.strip()),
len(apipilot_tools_pass1),
("token-present" if login_token else "skipped"),
len(inject_episode_id),
len(inject_phone),
@@ -381,7 +188,6 @@ async def realtime_proxy(ws: WebSocket) -> None:
len(injected_profile_context),
len(injected_reports_context),
len(injected_summary_context),
len(injected_apipilot_pass1),
upstream_url,
)
if settings.S2S_DEBUG_PROMPT and injected_episode_context.strip():
@@ -415,11 +221,6 @@ async def realtime_proxy(ws: WebSocket) -> None:
"injected_profile_context": injected_profile_context,
"injected_reports_context": injected_reports_context,
"injected_summary_context": injected_summary_context,
"injected_apipilot_pass1": injected_apipilot_pass1,
"inject_phone": inject_phone,
"inject_episode_id": inject_episode_id,
"login_token": login_token,
"login_response": login_response_obj,
# 2026-09-11 proper-name anglicization guard: keep the full
# upstream profile object so:
# (a) _build_voice_rules_head_lock_block can emit rule #5
@@ -429,24 +230,6 @@ async def realtime_proxy(ws: WebSocket) -> None:
# Alessandro with word-boundary regex even when the
# prompt lock fails on GPTQ.
"profile_obj": profile_obj,
"apipilot_tools_pass1": apipilot_tools_pass1,
# Accumulate transcripts per response_id and prevent pass-2
# injection loops after ApiPilot execution.
"_ws_apipilot_asst_transcript_acc": {}, # response_id -> str full transcript
"_ws_apipilot_pass2_active": False, # True during the second response; prevents infinite marker loops.
"_ws_apipilot_pass2_count": 0, # Session execution count, limited to three attempts.
# Defaults prepared by pass 1 fill missing pass-2 arguments.
"apipilot_defaults": (session_ctx_apipilot_defaults if needs_apipilot else {}),
# Pass-2 phone fallback also works when defaults are empty but
# the query string supplied phoneNumber.
"inject_phone": (inject_phone if isinstance(inject_phone, str) else ""), # noqa: F601
# Bearer for authenticated execution, resolved by phone login
# independently of S2S_INJECT_PROFILE.
"login_token": (login_token if isinstance(login_token, str) else None), # noqa: F601
# Execute with the exact auth/demo values used for WS discovery;
# AquaPortal binds the authorized tool list to that demo session.
"apipilot_exec_auth": _ws_apipilot_auth_for_execute,
"apipilot_exec_demo": _ws_apipilot_demo_for_execute,
}
client_to_upstream = asyncio.create_task(_client_to_upstream(ws, upstream, client_ip, session_ctx))
upstream_to_client = asyncio.create_task(_upstream_to_client(ws, upstream, client_ip, session_ctx))
@@ -542,7 +325,6 @@ async def _client_to_upstream(
ctx_profile_context = session_ctx.get("injected_profile_context") if isinstance(session_ctx, dict) else ""
ctx_reports_context = session_ctx.get("injected_reports_context") if isinstance(session_ctx, dict) else ""
ctx_summary_context = session_ctx.get("injected_summary_context") if isinstance(session_ctx, dict) else ""
ctx_apipilot_pass1 = session_ctx.get("injected_apipilot_pass1") if isinstance(session_ctx, dict) else ""
ctx_profile_obj = session_ctx.get("profile_obj") if isinstance(session_ctx, dict) else None
forward_text = services_context._patch_client_session_update_with_profile(
message["text"],
@@ -551,7 +333,6 @@ async def _client_to_upstream(
ctx_summary_context or "",
ctx_episode_context or "",
profile_obj=ctx_profile_obj,
apipilot_context_text_pass1=ctx_apipilot_pass1 or "",
)
await upstream.send(forward_text)
elif message.get("bytes") is not None:
@@ -576,38 +357,6 @@ async def _upstream_to_client(
) -> None:
async for message in upstream:
if isinstance(message, bytes):
# Drop audio only for the current response_id marked as containing
# a CALL marker: Qwen3 may already have spoken it. Matching any ID
# would also discard valid pass-2 audio.
drop_now = False
matched_rid = ""
drop_ids = session_ctx.get("_ws_drop_audio_for_response_ids")
if isinstance(drop_ids, dict) and drop_ids:
rid_ctx = session_ctx.get("_ws_current_response_id")
if isinstance(rid_ctx, str) and rid_ctx and drop_ids.get(rid_ctx):
drop_now = True
matched_rid = rid_ctx
elif (not isinstance(rid_ctx, str) or not rid_ctx) and len(drop_ids) == 1:
# Fallback when no current ID exists but exactly one response
# remains in the dictionary.
only_rid = next(iter(drop_ids.keys()))
if drop_ids.get(only_rid):
drop_now = True
matched_rid = only_rid
if drop_now:
app_logging.logger.info(
"[APIPILOT-AUDIO-DROP] FRAME DROP bytes=%dB matchedResponse=%s (CALL marker in this response → leaked Qwen3 audio DISCARDED)",
len(message),
(matched_rid or session_ctx.get("_ws_current_response_id") or "-"),
)
continue
else:
app_logging.logger.debug(
"[APIPILOT-AUDIO-ALLOW] FRAME PASS bytes=%dB responseCtx=%s dropIds=%s",
len(message),
(session_ctx.get("_ws_current_response_id") or "-"),
(sorted(list(drop_ids.keys())) if isinstance(drop_ids, dict) else "none"),
)
# #region debug-point B:upstream-bytes-forward
await realtime_events._report_server_debug(
"B",
@@ -623,535 +372,7 @@ async def _upstream_to_client(
# #endregion
await ws.send_bytes(message)
else:
# Extract the original message before sanitation removes pass-2 markers.
pre_sanitize_summary = realtime_events._realtime_event_summary(message)
pre_event_type = pre_sanitize_summary.get("type") if isinstance(pre_sanitize_summary, dict) else None
pre_response_id: str | None = None
if isinstance(pre_sanitize_summary, dict):
if isinstance(pre_sanitize_summary.get("response_id"), str):
pre_response_id = pre_sanitize_summary["response_id"]
elif isinstance(pre_sanitize_summary.get("id"), str):
pre_response_id = pre_sanitize_summary["id"]
# Track the current response_id for the binary-audio gate.
if isinstance(pre_response_id, str) and pre_response_id:
session_ctx["_ws_current_response_id"] = pre_response_id
# Detect CALL markers in every text event, including before response.done.
# Mark this response_id to drop its audio and sanitize later events.
if (
isinstance(pre_sanitize_summary, dict)
and isinstance(pre_event_type, str)
and pre_event_type not in {"response.done"}
):
quick_text_parts: list[str] = []
for _k in (
"text",
"transcript",
"output_text",
"audio_text",
"output_transcript",
"final_transcript",
"output_audio_text",
"content",
"message",
"utterance",
"delta",
"_raw_preview",
):
_v = pre_sanitize_summary.get(_k)
if isinstance(_v, str) and _v.strip():
quick_text_parts.append(_v)
for _cname in ("item", "part", "output", "audio"):
_sub = pre_sanitize_summary.get(_cname)
if isinstance(_sub, dict):
for _k2 in ("text", "transcript", "output_text"):
_v2 = _sub.get(_k2)
if isinstance(_v2, str) and _v2.strip():
quick_text_parts.append(_v2)
quick_text = (" ".join(quick_text_parts)).strip()
if quick_text:
_detected_name, _detected_args = services_tool_context._apipilot_parse_call_marker(quick_text)
if isinstance(_detected_name, str) and _detected_name:
drop_ids2 = session_ctx.get("_ws_drop_audio_for_response_ids")
if not isinstance(drop_ids2, dict):
drop_ids2 = {}
session_ctx["_ws_drop_audio_for_response_ids"] = drop_ids2
_flag_rid = (
pre_response_id
if isinstance(pre_response_id, str) and pre_response_id
else f"auto-{id(session_ctx)}"
)
drop_ids2[_flag_rid] = True
# Remove older IDs when more than ten remain.
if len(drop_ids2) > 10:
try:
for _old_k in list(drop_ids2.keys())[:-10]:
drop_ids2.pop(_old_k, None)
except Exception:
pass
app_logging.logger.info(
"[APIPILOT-SANITIZE] CALL marker in event=%s response=%s name=%s → AUDIO DROP GATE enabled (discard potential leaked Qwen3 audio)",
pre_event_type,
_flag_rid,
_detected_name,
)
# 1a) Accumulate original TTS transcripts for pass-2 marker parsing.
if (
isinstance(pre_sanitize_summary, dict)
and isinstance(pre_event_type, str)
and pre_event_type in {"response.audio_transcript.done", "response.output_audio_transcript.done"}
):
pre_agent_text = (
cast(str, pre_sanitize_summary.get("text"))
if isinstance(pre_sanitize_summary.get("text"), str)
else ""
).strip()
rid_key = (
pre_response_id
if isinstance(pre_response_id, str) and pre_response_id
else f"auto-{id(session_ctx)}"
)
if rid_key and pre_agent_text:
acc = session_ctx.get("_ws_apipilot_asst_transcript_acc")
if not isinstance(acc, dict):
acc = {}
session_ctx["_ws_apipilot_asst_transcript_acc"] = acc
prev = acc.get(rid_key, "")
if prev:
acc[rid_key] = f"{prev} {pre_agent_text}".strip()
else:
acc[rid_key] = pre_agent_text
# 1b) Also accumulate assistant conversation.item.created text,
# which can contain markers outside TTS transcripts.
elif (
isinstance(pre_sanitize_summary, dict)
and isinstance(pre_event_type, str)
and pre_event_type == "conversation.item.created"
and pre_sanitize_summary.get("role") == "assistant"
):
pre_conv_text = (
cast(str, pre_sanitize_summary.get("text"))
if isinstance(pre_sanitize_summary.get("text"), str)
else ""
).strip()
rid_key2 = (
pre_response_id
if isinstance(pre_response_id, str) and pre_response_id
else f"auto-{id(session_ctx)}"
)
if rid_key2 and pre_conv_text:
acc2 = session_ctx.get("_ws_apipilot_asst_transcript_acc")
if not isinstance(acc2, dict):
acc2 = {}
session_ctx["_ws_apipilot_asst_transcript_acc"] = acc2
prev2 = acc2.get(rid_key2, "")
if prev2:
acc2[rid_key2] = f"{prev2} {pre_conv_text}".strip()
else:
acc2[rid_key2] = pre_conv_text
# 2) Parse the original marker at response.done to trigger pass 2.
if (
isinstance(pre_sanitize_summary, dict)
and isinstance(pre_event_type, str)
and pre_event_type == "response.done"
):
rid_val: str = (
pre_response_id
if isinstance(pre_response_id, str)
else (
cast(str, pre_sanitize_summary.get("id"))
if isinstance(pre_sanitize_summary.get("id"), str)
else f"auto-{id(session_ctx)}"
)
)
full_text = ""
acc3 = session_ctx.get("_ws_apipilot_asst_transcript_acc")
if rid_val and isinstance(acc3, dict):
full_text = (acc3.pop(rid_val, "") if isinstance(acc3, dict) else "") or ""
# Use the sole remaining accumulator when an upstream changes
# response_id between events.
if (
(not isinstance(full_text, str) or not full_text.strip())
and isinstance(acc3, dict)
and len(acc3) == 1
):
_only_key = next(iter(acc3.keys()))
full_text = (acc3.pop(_only_key, "") if isinstance(acc3, dict) else "") or ""
marker_name, marker_args = None, None
if isinstance(full_text, str) and full_text.strip():
_p1_defaults = (
session_ctx.get("apipilot_defaults")
if isinstance(session_ctx.get("apipilot_defaults"), dict)
else {}
)
marker_name, marker_args = services_tool_context._apipilot_parse_call_marker(
full_text, defaults=_p1_defaults
)
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=WS_RESPONSE_DONE_DETECTION markerFound=%s name=%r argsKeys=%s",
("Y" if isinstance(marker_name, str) and marker_name else "N"),
(marker_name or ""),
list((marker_args or {}).keys()),
)
# Always log pass-2 marker detection diagnostics.
try:
_preview = full_text.replace("\n", " ")
if len(_preview) > 500:
_preview = _preview[:500] + "…"
except Exception:
_preview = str(full_text)[:500]
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): response.done DIAGNOSTIC fullLen=%d markerFound=%s markerName=%s argsKeys=%s defaultsAutoFill=%s acc3Remaining=%d fullTextPreview=%r",
len(full_text or ""),
("Y" if isinstance(marker_name, str) and marker_name else "N"),
(marker_name if isinstance(marker_name, str) and marker_name else ""),
(list(marker_args.keys()) if isinstance(marker_args, dict) else []),
(list(_p1_defaults.keys()) if isinstance(_p1_defaults, dict) and _p1_defaults else "none"),
(len(acc3) if isinstance(acc3, dict) else -1),
_preview,
)
elif isinstance(full_text, str):
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): response.done DIAGNOSTIC fullLen=0 EMPTY markerFound=N response_id=%r acc3Remaining=%d",
rid_val,
(len(acc3) if isinstance(acc3, dict) else -1),
)
else:
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): response.done DIAGNOSTIC fullText is not str type=%r acc3=%r",
type(full_text).__name__,
(list(acc3.keys()) if isinstance(acc3, dict) else "?"),
)
needs_pass2 = (
isinstance(marker_name, str)
and bool(marker_name)
and not bool(session_ctx.get("_ws_apipilot_pass2_active"))
and (int(session_ctx.get("_ws_apipilot_pass2_count") or 0) < 3)
)
if needs_pass2:
# Clear active audio-drop gates before pass 2: its response
# contains actual user data and must be played normally.
drop_ids_clean = session_ctx.get("_ws_drop_audio_for_response_ids")
if isinstance(drop_ids_clean, dict) and drop_ids_clean:
drop_ids_clean.clear()
app_logging.logger.info(
"[APIPILOT-AUDIO-DROP] PASS-2 start: clear audio-drop gate → forward pass-2 briefing audio normally"
)
args = marker_args if isinstance(marker_args, dict) else {}
# Fill missing arguments or replace fictional prompt example
# values with actual defaults. When defaults are empty, use
# session_ctx["inject_phone"] from the query string.
_p1_defaults_use = (
session_ctx.get("apipilot_defaults")
if isinstance(session_ctx.get("apipilot_defaults"), dict)
else {}
)
_fallback_inject_phone = (
session_ctx.get("inject_phone") if isinstance(session_ctx.get("inject_phone"), str) else ""
)
# Known fictional example values appearing in prompts.
_FAKE_PHONE_PLACEHOLDERS: set[str] = {
"+56912345678", # Prompt example from rule 7.
"56912345678", # Variant without +.
"+5612345678",
"+1234567890",
"+12345678",
"+56XXXXXXX",
"+56xxxxxxx",
"",
}
def _is_phone_fake(v: str) -> bool:
c = str(v or "").strip()
if c in _FAKE_PHONE_PLACEHOLDERS: # noqa: B023
return True
if len(c) < 7:
return True
if c.endswith("00000"):
return True
if "123456" in c or "xxxxxx" in c.lower() or "xxxx" in c.lower():
return True
if c.startswith("+56") and len(c) <= 9:
return True
return False
# Apply defaults and fallback to all available keys.
if isinstance(_p1_defaults_use, dict) and _p1_defaults_use:
for k, default_v in _p1_defaults_use.items():
if default_v in (None, "", []):
continue
if k not in args or args[k] in (None, "", []):
args[k] = default_v
elif k == "phone" and isinstance(args.get("phone"), str):
if _is_phone_fake(str(args.get("phone"))):
args[k] = default_v
# Replace fictional phone values even when defaults lacks phone.
if (
isinstance(args.get("phone"), str)
and _is_phone_fake(str(args.get("phone")))
and isinstance(_fallback_inject_phone, str)
and _fallback_inject_phone.strip()
):
args["phone"] = _fallback_inject_phone.strip()
# Refresh _p1_defaults for subsequent logging.
_p1_defaults = _p1_defaults_use
session_ctx["_ws_apipilot_pass2_count"] = int(session_ctx.get("_ws_apipilot_pass2_count") or 0) + 1
session_ctx["_ws_apipilot_pass2_active"] = True
# Reuse exact discovery auth/demo values: AquaPortal binds
# authorized tools to the x-demo-session used for GET /tools.
_exec_auth = (
session_ctx.get("apipilot_exec_auth")
if isinstance(session_ctx.get("apipilot_exec_auth"), str)
else None
)
_exec_demo = (
session_ctx.get("apipilot_exec_demo")
if isinstance(session_ctx.get("apipilot_exec_demo"), str)
else None
)
# Compatibility fallback for missing values:
if not isinstance(_exec_auth, str) or not _exec_auth.strip():
login_tok = session_ctx.get("login_token")
_exec_auth = f"Bearer {login_tok}" if isinstance(login_tok, str) and login_tok.strip() else None
if not isinstance(_exec_demo, str) or not _exec_demo.strip():
_exec_demo = f"ws-pass2-{client_ip}-{id(session_ctx)}"
# The SUPER API KEY overrides even SERVICE_TOKEN. Force bearer
# and demo-session values to match the discovery bypass identity.
if settings.S2S_APIPILOT_API_KEY:
_exec_auth = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
_exec_demo = settings.S2S_APIPILOT_API_KEY
# Apply the service-token override only without a SUPER API KEY.
elif settings.S2S_APIPILOT_SERVICE_TOKEN:
_svc = settings.S2S_APIPILOT_SERVICE_TOKEN.strip()
if _svc:
_exec_auth = f"Bearer {_svc}" if not _svc.lower().startswith("bearer ") else _svc
# For authenticated tools with an empty/short bearer, force
# phone login again so AquaPortal receives a valid user token.
# The length heuristic treats fewer than 80 characters as short.
_phone_lfb = (
session_ctx.get("inject_phone") if isinstance(session_ctx.get("inject_phone"), str) else ""
) or (inject_phone if isinstance(inject_phone, str) else "") # type: ignore[name-defined] # noqa: F821 - preserve the legacy fallback failure.
_phone_lfb = _phone_lfb.strip() if isinstance(_phone_lfb, str) else ""
_current_bearer_token = ""
if isinstance(_exec_auth, str) and _exec_auth.lower().startswith("bearer "):
_current_bearer_token = _exec_auth[len("bearer ") :].strip()
_is_jwt_like = (
bool(_current_bearer_token)
and len(_current_bearer_token) >= 120
and _current_bearer_token.count(".") >= 2
)
# Resolve requires_auth from recent session/cache tools; default true.
_tools_recent: list[dict] = (
cast(list[dict], session_ctx.get("apipilot_tools_pass1"))
if isinstance(session_ctx.get("apipilot_tools_pass1"), list)
else []
)
if not _tools_recent:
try:
_cached = (
providers_apipilot._APIPILOT_CACHE.get("tools")
if providers_apipilot._APIPILOT_CACHE
else None
)
if isinstance(_cached, tuple) and _cached and isinstance(_cached[0], list):
_tools_recent = _cached[0]
except Exception: # noqa: BLE001
_tools_recent = []
_mtool = next(
(t for t in _tools_recent if isinstance(t, dict) and t.get("name") == marker_name), None
)
_requires_auth = True if (_mtool is None) else bool(_mtool.get("requires_auth"))
if (
(not settings.S2S_APIPILOT_API_KEY)
and settings.S2S_LOGIN_BY_PHONE
and _phone_lfb
and _requires_auth
and (not _is_jwt_like)
):
try:
_l_tok, _l_obj = await providers_caller._login_by_phone(_phone_lfb)
if isinstance(_l_tok, str) and _l_tok.strip():
_exec_auth = f"Bearer {_l_tok.strip()}"
_tok_len = len(_l_tok.strip())
_is_new_jwt = _tok_len >= 120 and _l_tok.strip().count(".") >= 2
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): FORCED PASS-2 LOGIN (tool requires_auth) phoneLen=%d token=%s%s",
len(_phone_lfb),
f"YES len={_tok_len}",
(" JWT?" if _is_new_jwt else (" SHORT?" if _tok_len < 80 else " OK")),
)
else:
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): FORCED PASS-2 LOGIN token EXTRACTION FAILED phoneLen=%d (tool requires_auth → likely 403)",
len(_phone_lfb),
)
except Exception as _lfb_exc: # noqa: BLE001
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): FORCED PASS-2 LOGIN failed phoneLen=%d err=%r",
len(_phone_lfb),
_lfb_exc,
)
bearer = _exec_auth
demo_key = _exec_demo
try:
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=WS_REALTIME_PASS2_TRIGGER name=%r argsKeys=%s argsPhone=%r",
marker_name,
list(args.keys()) if args else [],
(args.get("phone") if isinstance(args.get("phone"), str) else ""),
)
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): trigger marker name=%s argsKeys=%s argsPhone=%s auth=%s defaultsAutoFill=%s demoSessionKey=%.40s",
marker_name,
list(args.keys()) if args else [],
(args.get("phone") if isinstance(args.get("phone"), str) else ""),
"Y" if bearer else "anon",
list(_p1_defaults.keys()) if isinstance(_p1_defaults, dict) and _p1_defaults else "none",
demo_key if isinstance(demo_key, str) else "?",
)
t0 = providers_apipilot._monotonic_now()
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=WS_REALTIME_PASS2_EXEC_HELPER name=%r → forward a _apipilot_execute_tool",
marker_name,
)
env_exec = providers_apipilot._apipilot_execute_tool(
auth_header=bearer,
demo_session=demo_key,
name=cast(str, marker_name),
arguments=args,
)
if hasattr(asyncio, "create_task"):
envelope = await asyncio.wait_for(env_exec, timeout=60.0)
else:
envelope = await env_exec
t1 = providers_apipilot._monotonic_now() - t0
ok = bool(isinstance(envelope, dict) and envelope.get("success"))
data = envelope.get("data") if isinstance(envelope, dict) else None
err_obj = (
envelope.get("error")
if isinstance(envelope, dict) and isinstance(envelope.get("error"), dict)
else None
)
if isinstance(data, str):
tool_text = data
elif isinstance(data, (dict, list)):
try:
tool_text = json.dumps(data, indent=2, ensure_ascii=False)
except Exception: # noqa: BLE001
tool_text = str(data)
else:
tool_text = ""
if not ok:
code = (
err_obj.get("code")
if isinstance(err_obj, dict) and err_obj.get("code") is not None
else (envelope.get("code") if isinstance(envelope, dict) else None)
)
msg = (
err_obj.get("message")
if isinstance(err_obj, dict) and isinstance(err_obj.get("message"), str)
else ""
)
prefix = ""
if code is not None or msg:
prefix = f"[{code if code is not None else 'ERROR'}] {msg}".strip() + "\n"
tool_text = f"{prefix}{tool_text}".strip()
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): execute DONE name=%s success=%s len=%d t=%.0fms",
marker_name,
"ok" if ok else "error",
len(tool_text),
t1,
)
call_result_dict: dict[str, Any] = {
"name": marker_name,
"text": tool_text or "",
"error": (not ok),
}
# Use the light internal sanitizer for pass 2. The user-output
# sanitizer would remove the RESULT wrapper and prevent the
# upstream model from receiving the briefing result.
len_raw_tool_text = len(call_result_dict.get("text") or "")
if isinstance(call_result_dict.get("text"), str):
call_result_dict["text"] = services_tool_context._sanitize_pass2_internal_context(
call_result_dict["text"]
)
len_sanitized_tool_text = len(call_result_dict.get("text") or "")
blk_pass2 = (
services_tool_context._resolve_apipilot_reasoning_context_block(
None,
call_result=call_result_dict,
)
or ""
)
if not blk_pass2.strip():
blk_pass2 = (tool_text or "").strip()
len_raw_wrapper = len(blk_pass2 or "")
# Light sanitation preserves the pass-2 RESULT wrapper.
if blk_pass2.strip():
blk_pass2 = services_tool_context._sanitize_pass2_internal_context(blk_pass2)
len_sanitized_wrapper = len(blk_pass2 or "")
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): block SIZES len_raw_tool_text=%d len_sanitized_tool_text=%d len_raw_wrapper=%d len_sanitized_wrapper=%d success=%s",
len_raw_tool_text,
len_sanitized_tool_text,
len_raw_wrapper,
len_sanitized_wrapper,
("ok" if ok else "error"),
)
if blk_pass2.strip():
# Reset _ws_current_response_id before pass-2 injection so
# valid briefing audio cannot match the previous CALL response.
session_ctx["_ws_current_response_id"] = None
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): inject PASS-2 block len=%d -> send conversation.item.create + response.create upstream",
len(blk_pass2),
)
try:
await upstream.send(
json.dumps(
{
"type": "conversation.item.create",
"item": {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": blk_pass2}],
},
},
ensure_ascii=False,
)
)
await upstream.send(json.dumps({"type": "response.create"}, ensure_ascii=False))
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): injection OK. Awaiting model response (pass2_active=Y until next response.done)",
)
except Exception as exc2: # noqa: BLE001
session_ctx["_ws_apipilot_pass2_active"] = False
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): inject FAIL upstream send err=%r. pass2_active=N.",
exc2,
)
else:
session_ctx["_ws_apipilot_pass2_active"] = False
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): EMPTY result block -> skip injection. pass2_active=N.",
)
except Exception as exc: # noqa: BLE001
session_ctx["_ws_apipilot_pass2_active"] = False
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): ABORT marker=%s err=%r. pass2_active=N (safety).",
marker_name,
exc,
)
elif isinstance(session_ctx.get("_ws_apipilot_pass2_active"), bool) and bool(
session_ctx.get("_ws_apipilot_pass2_active")
):
session_ctx["_ws_apipilot_pass2_active"] = False
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): response.done PASS-2 FINALE -> pass2_active=N safety counter=%s",
int(session_ctx.get("_ws_apipilot_pass2_count") or 0),
)
# Layer 7 post-processing: after marker handling, safely remove
# internal markers before forwarding text to the client.
# Remove internal context markers before forwarding text to the client.
message, was_sanitized = realtime_events._sanitize_realtime_event_raw(message, session_ctx)
# #region debug-point B:upstream-text-forward
summary = realtime_events._realtime_event_summary(message)
@@ -1184,8 +405,7 @@ async def _upstream_to_client(
# Agent output, including all event variants.
agent_text = ""
if event_type in {"response.audio_transcript.done", "response.output_audio_transcript.done"}:
# Pass-2 accumulation already retained the original text.
# This sanitized agent_text is only for UI and logs.
# Use the sanitized agent text for UI and logs.
agent_text = (summary.get("text") or "").strip()
if agent_text:
app_logging.logger.info(
+10 -60
View File
@@ -6,7 +6,6 @@ from typing import Any, cast
from vocero_api.core import config as settings
from vocero_api.core import logging as app_logging
from vocero_api.services import tool_context as services_tool_context
VOCERO_USER_CONTEXT_HINT = " Caller profile resolved from the page phoneNumber: "
REPORTS_LIST_HINT = (
@@ -334,8 +333,6 @@ _CONTEXT_DEDUPE_MARKERS: tuple = (
("summary_full", SUMMARY_FULL_MARKER),
("today_fixed", TODAY_BLOCK_MARKER),
("reports_minimal", MINIMAL_REPORTS_MARKER),
("apipilot_tools_list", services_tool_context.APIPILOT_PASS1_LIST_MARKER),
("apipilot_result", services_tool_context.APIPILOT_PASS2_RESULT_MARKER),
)
@@ -971,25 +968,19 @@ def _patch_client_session_update_with_profile(
summary_context_text: str | None = None,
episode_context_text: str | None = None,
profile_obj: Any = None,
apipilot_context_text_pass1: str | None = None,
) -> str:
"""If raw_text is a client session.update JSON and S2S_INJECT_PROFILE is
true, append episode_context_text + user_context_text + (optional) reports_context_text +
(optional) summary_context_text + (optional) apipilot_context_text_pass1 to session.instructions.
(optional) summary_context_text to session.instructions.
Returns the modified JSON string, or the original string unchanged if:
- it is not session.update JSON (the VAST majority of frames -> silent passthrough, NO LOG)
- episode_context_text AND user_context_text AND reports_context_text AND summary_context_text AND apipilot_context_text_pass1 are all empty
- S2S_INJECT_PROFILE is False (but if we have apipilot_context_text_pass1 -> PATCH even if S2S_INJECT_PROFILE=false, tools injection is independent.)
- episode_context_text AND user_context_text AND reports_context_text AND summary_context_text are all empty
- S2S_INJECT_PROFILE is False
- parse fails
2026-09-11: NEW keyword profile_obj. Forwarded to
_augment_instructions_with_profile so the prompt lock can emit the proper-name
anti-anglicization rule for this caller (e.g. Alessandro never Alexander).
2026-09-25: NEW keyword apipilot_context_text_pass1. Injected IN ADDITION so WS
Realtime LLM sees the tools list match description / marker rules (same as the
/api/reasoning HTTP endpoint). Independent gate: even S2S_INJECT_PROFILE=false,
if apipilot_context_text_pass1 has content -> PATCH instructions.
"""
# Step 1: only consider session.update frames. All other frame types
# (response.create, conversation.item.*, audio_* etc.) pass through
@@ -1005,10 +996,9 @@ def _patch_client_session_update_with_profile(
# From here on: we know this is a real session.update (typically emitted
# just once or twice per realtime session). We can log safely — 1 line,
# not 20 repeated lines.
has_apipilot_pass1 = bool((apipilot_context_text_pass1 or "").strip())
if not settings.S2S_INJECT_PROFILE and not has_apipilot_pass1:
if not settings.S2S_INJECT_PROFILE:
app_logging.logger.info(
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (S2S_INJECT_PROFILE=false and no apipilot list pass1). "
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (S2S_INJECT_PROFILE=false). "
"WS stays pure passthrough."
)
return raw_text
@@ -1016,9 +1006,9 @@ def _patch_client_session_update_with_profile(
has_profile = bool((user_context_text or "").strip())
has_reports = bool((reports_context_text or "").strip())
has_summary = bool((summary_context_text or "").strip())
if not (has_episode or has_profile or has_reports or has_summary or has_apipilot_pass1):
if not (has_episode or has_profile or has_reports or has_summary):
app_logging.logger.info(
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (no episode, no profile context, no reports list, no latest summary, no apipilot tools list pass1). "
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (no episode, no profile context, no reports list, no latest summary). "
"Probably phoneNumber/episode_id query param missing / fetch failed upstream / "
"endpoints or S2S_LATEST_SUMMARY / S2S_REPORT_LIST returned empty result."
)
@@ -1038,12 +1028,6 @@ def _patch_client_session_update_with_profile(
"[INJECT-REPORTS] profile missing, but REPORTS LIST block loaded. "
"Will inject reports alone into session.update."
)
if has_apipilot_pass1 and not has_profile and not has_episode and not has_reports and not has_summary:
app_logging.logger.info(
"[INJECT-APIPILOT] profile/episode/reports/summary missing, but APIPILOT TOOLS list PASS-1 block loaded (%d chars). "
"Will inject tools list alone into session.update.",
len(apipilot_context_text_pass1) if isinstance(apipilot_context_text_pass1, str) else 0,
)
top_session = payload.get("session")
if not isinstance(top_session, dict):
return raw_text
@@ -1064,22 +1048,10 @@ def _patch_client_session_update_with_profile(
len(current_instructions),
current_instructions,
)
# Append ApiPilot pass-1 tools and marker rules after profile/report/episode/
# summary context, matching /api/reasoning. If a second client session.update
# overwrites instructions without the tool-list marker, append the list again.
already_has_apipilot_pass1 = (
isinstance(current_instructions, str)
and services_tool_context.APIPILOT_PASS1_LIST_MARKER in current_instructions
)
if has_apipilot_pass1 and not already_has_apipilot_pass1:
augmented = (augmented + "\n\n" if augmented.strip() else "") + (
apipilot_context_text_pass1 if isinstance(apipilot_context_text_pass1, str) else ""
)
# Do not skip a tool-list repair even if profile augmentation was unchanged.
if augmented == current_instructions and not (has_apipilot_pass1 and not already_has_apipilot_pass1):
if augmented == current_instructions:
app_logging.logger.info(
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (augmented == current; "
"episode/profile already injected or context empty or apipilot same)"
"episode/profile already injected or context empty)"
)
if settings.S2S_DEBUG_PROMPT and augmented.strip():
app_logging.logger.info(
@@ -1091,7 +1063,7 @@ def _patch_client_session_update_with_profile(
top_session["instructions"] = augmented
app_logging.logger.info(
"[INJECT-PROFILE] ✅ WS session.update PATCHED -> upstream instructions. "
"origLen=%d newLen=%d injectedLen=%d episode=%s profile=%s reports=%s summary=%s apipilotPass1=%s(len=%d, wasAlreadyPresent=%s)",
"origLen=%d newLen=%d injectedLen=%d episode=%s profile=%s reports=%s summary=%s",
len(current_instructions),
len(augmented),
(len(augmented) - len(current_instructions)),
@@ -1099,9 +1071,6 @@ def _patch_client_session_update_with_profile(
has_profile,
has_reports,
has_summary,
has_apipilot_pass1,
(len(apipilot_context_text_pass1) if isinstance(apipilot_context_text_pass1, str) else 0),
("true" if already_has_apipilot_pass1 else "false"),
)
if settings.S2S_DEBUG_PROMPT:
app_logging.logger.info(
@@ -1121,25 +1090,6 @@ def _patch_client_session_update_with_profile(
len(reports_context_text.strip()),
reports_context_text.strip(),
)
if (
settings.S2S_DEBUG_PROMPT
and isinstance(apipilot_context_text_pass1, str)
and apipilot_context_text_pass1.strip()
):
app_logging.logger.info(
"[DEBUG-APIPILOT][WS session.update] FULL ApiPilot tools list PASS-1 block (len=%d):\n%s\n",
len(apipilot_context_text_pass1.strip()),
_strip_internal_markers(apipilot_context_text_pass1),
)
# Also log the FINAL upstream instructions when S2S_DEBUG_PROMPT is on and
# this is a session.update (so we see the EXACT text reaching the model even
# when S2S_INJECT_PROFILE is off and no patch happened.)
if (not settings.S2S_INJECT_PROFILE) and settings.S2S_DEBUG_PROMPT and current_instructions.strip():
app_logging.logger.info(
"[DEBUG-PROMPT][WS session.update] FINAL upstream instructions (injectProfile=false, len=%d):\n%s\n",
len(current_instructions),
current_instructions,
)
try:
return json.dumps(payload)
except Exception:
+1 -1
View File
@@ -25,7 +25,7 @@ _CALL = re.compile(r"<<<VOCERO_MCP_CALL:NAME=([A-Za-z0-9_]+),ARGUMENTS=(.*)>>>",
def selection(text: str) -> tuple[str, dict[str, Any]] | None:
"""Use a marker that survives the speech pipeline's ApiPilot sanitizer."""
"""Parse the internal tool selection marker returned by the speech model."""
if "VOCERO_MCP" not in text.upper() and not text.lstrip().startswith("<<<"):
return None
match = _CALL.fullmatch(text.strip())
-256
View File
@@ -11,11 +11,9 @@ from vocero_api.core import config as settings
from vocero_api.core import logging as app_logging
from vocero_api.models import requests as models_requests
from vocero_api.models.responses import ReasoningResponse
from vocero_api.providers import apipilot as providers_apipilot
from vocero_api.providers import caller as providers_caller
from vocero_api.services import context as services_context
from vocero_api.services import mcp_reasoning
from vocero_api.services import tool_context as services_tool_context
def _openai_headers() -> dict[str, str]:
@@ -182,45 +180,6 @@ async def reasoning(req: models_requests.ReasoningRequest, request: Request) ->
app_logging.logger.info(
"[INJECT-SUMMARY] ⏭️ REASONING: latest summary skipped (context empty / upstream returned empty summary / fetch failed.)"
)
# Load dynamic ApiPilot tools before building context, independently of
# S2S_INJECT_PROFILE.
needs_apipilot = bool(settings.S2S_APIPILOT_TOOLS_URL) or bool(settings.S2S_APIPILOT_TOOLS_EXECUTE_URL)
apipilot_tools_pass1: list[dict] = []
apipilot_auth_header: str | None = None
if isinstance(login_token, str) and login_token.strip():
tk = login_token.strip()
if tk.lower().startswith("bearer "):
apipilot_auth_header = tk
else:
apipilot_auth_header = f"Bearer {tk}"
# S2S_LOGIN_BY_PHONE resolves an AquaPortal JWT, used for requires_auth=true
# execution. S2S_APIPILOT_SERVICE_TOKEN overrides it when configured.
if settings.S2S_APIPILOT_SERVICE_TOKEN:
_svc = settings.S2S_APIPILOT_SERVICE_TOKEN.strip()
if _svc:
apipilot_auth_header = f"Bearer {_svc}" if not _svc.lower().startswith("bearer ") else _svc
if needs_apipilot:
try:
apipilot_t0 = providers_apipilot._monotonic_now()
apipilot_tools_pass1 = await providers_apipilot._apipilot_fetch_tools(
auth_header=apipilot_auth_header,
demo_session=None,
force_refresh=False,
)
app_logging.logger.info(
"[APIPILOT] REASONING PASS-1: tool list ready N=%d t=%dms auth=%s",
len(apipilot_tools_pass1),
int((providers_apipilot._monotonic_now() - apipilot_t0) * 1000),
"Y" if apipilot_auth_header else "-",
)
except Exception as exc:
app_logging.logger.warning("[APIPILOT] REASONING: tool-list fetch failed, ignoring: %s", exc)
apipilot_tools_pass1 = []
inject_apipilot_text_pass1 = services_tool_context._resolve_apipilot_reasoning_context_block(
apipilot_tools_pass1,
call_result=None,
)
user_context_parts: list[str] = []
# ─── USER FIX BUG 3: TODAY fixed context block → PREPEND to user_context too
# (the same anti-hallucination block gets prefixed in the SYSTEM instructions
@@ -242,8 +201,6 @@ async def reasoning(req: models_requests.ReasoningRequest, request: Request) ->
user_context_parts.append(inject_reports_text)
if inject_summary_text:
user_context_parts.append(inject_summary_text)
if inject_apipilot_text_pass1:
user_context_parts.append(inject_apipilot_text_pass1)
original_user_context = (req.user_context or "").strip()
if original_user_context:
user_context_parts.append(original_user_context)
@@ -334,18 +291,6 @@ async def reasoning(req: models_requests.ReasoningRequest, request: Request) ->
len(inject_profile_text),
services_context._strip_internal_markers(inject_profile_text),
)
if settings.S2S_DEBUG_PROMPT and inject_apipilot_text_pass1.strip():
app_logging.logger.info(
"[DEBUG-APIPILOT][REASONING augment] FULL ApiPilot tools list block (len=%d):\n%s\n",
len(inject_apipilot_text_pass1),
services_context._strip_internal_markers(inject_apipilot_text_pass1),
)
if needs_apipilot and inject_apipilot_text_pass1.strip():
app_logging.logger.info(
"[INJECT-APIPILOT] REASONING PASS-1: inject tool list (len=%d, Ntools=%d) into instructions and user_context.",
len(inject_apipilot_text_pass1),
len(apipilot_tools_pass1),
)
effective_instructions = services_context._augment_instructions_with_profile(
req.instructions or "",
inject_profile_text,
@@ -354,28 +299,8 @@ async def reasoning(req: models_requests.ReasoningRequest, request: Request) ->
inject_episode_text,
profile_obj=profile_obj,
)
if inject_apipilot_text_pass1.strip():
effective_instructions = (
effective_instructions + "\n\n" if effective_instructions else ""
) + inject_apipilot_text_pass1
else:
effective_instructions = (req.instructions or "").strip()
if inject_apipilot_text_pass1.strip():
app_logging.logger.info(
"[INJECT-APIPILOT] REASONING PASS-1: inject tool list (len=%d, Ntools=%d) into instructions (inject_profile=false).",
len(inject_apipilot_text_pass1),
len(apipilot_tools_pass1),
)
effective_instructions = (
effective_instructions + "\n\n" if effective_instructions else ""
) + inject_apipilot_text_pass1
if settings.S2S_DEBUG_PROMPT and inject_apipilot_text_pass1.strip():
app_logging.logger.info(
"[DEBUG-APIPILOT][REASONING augment] FULL ApiPilot tools list block (len=%d):\n%s\n",
len(inject_apipilot_text_pass1),
services_context._strip_internal_markers(inject_apipilot_text_pass1),
)
# Call the OpenAI-compatible Chat Completions provider.
if not settings.OPENAI_API_KEY:
raise HTTPException(status_code=503, detail="OPENAI_API_KEY no está configurado.")
@@ -385,8 +310,6 @@ async def reasoning(req: models_requests.ReasoningRequest, request: Request) ->
system_parts.append(instructions)
if effective_user_context.strip():
system_parts.append(f"User context:\n{effective_user_context.strip()}")
if inject_apipilot_text_pass1:
system_parts.append(inject_apipilot_text_pass1)
if req.language.strip():
system_parts.append(
"Reply only with the final assistant answer text. "
@@ -474,183 +397,4 @@ async def reasoning(req: models_requests.ReasoningRequest, request: Request) ->
)
raise HTTPException(status_code=502, detail="El servicio de razonamiento de OpenAI no devolvió una respuesta.")
app_logging.logger.info("[Vocero REASONING] engine=OPENAI transcript=%s answer=%s", transcript[:300], answer[:500])
# Capture the ApiPilot marker, execute its tool and run OpenAI pass 2.
tool_name_p1_oai, tool_args_p1_oai = services_tool_context._apipilot_parse_call_marker(answer)
if tool_name_p1_oai:
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=OPENAI_PASS2_TRIGGER name=%r argsKeys=%s",
tool_name_p1_oai,
sorted(list((tool_args_p1_oai or {}).keys())),
)
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 trigger OPENAI marker name=%s args=%s",
tool_name_p1_oai,
json.dumps(tool_args_p1_oai or {}, ensure_ascii=True)[:300],
)
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=OPENAI_PASS2_EXEC_HELPER name=%r → forward to _apipilot_execute_tool",
tool_name_p1_oai,
)
tool_env_oai_p1 = await providers_apipilot._apipilot_execute_tool(
auth_header=apipilot_auth_header,
demo_session=None,
name=tool_name_p1_oai,
arguments=tool_args_p1_oai or {},
)
is_tool_err_oai = not bool(tool_env_oai_p1.get("success"))
tool_data_oai = tool_env_oai_p1.get("data")
if isinstance(tool_data_oai, str):
tool_text_oai = tool_data_oai
elif tool_data_oai is None:
tool_text_oai = ""
else:
try:
tool_text_oai = json.dumps(tool_data_oai, ensure_ascii=False, indent=2)
except Exception:
tool_text_oai = str(tool_data_oai)
if is_tool_err_oai and isinstance(tool_env_oai_p1.get("error"), dict):
err_oai = tool_env_oai_p1["error"]
err_oai_head = f"[{err_oai.get('code')}] {err_oai.get('message')}"
tool_text_oai = (err_oai_head + ("\n" + tool_text_oai if tool_text_oai.strip() else "")).strip()
inject_apipilot_pass2_oai = services_tool_context._resolve_apipilot_reasoning_context_block(
[],
call_result={
"name": tool_name_p1_oai,
"text": tool_text_oai,
"error": is_tool_err_oai,
},
)
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 OPENAI execute name=%s success=%s resultLen=%d → second chat/completions call",
tool_name_p1_oai,
str(not is_tool_err_oai).lower(),
len(tool_text_oai),
)
# Build pass 2 with the same instructions/context structure, replacing
# inject_apipilot_text_pass1 with the result block inject_apipilot_pass2_oai.
user_ctx_oai_2: list[str] = []
if voice_lock_block.strip():
user_ctx_oai_2.append(voice_lock_block)
if today_block.strip():
user_ctx_oai_2.append(today_block)
if inject_episode_text:
user_ctx_oai_2.append(inject_episode_text)
if inject_profile_text:
user_ctx_oai_2.append(inject_profile_text)
if inject_reports_text:
user_ctx_oai_2.append(inject_reports_text)
if inject_summary_text:
user_ctx_oai_2.append(inject_summary_text)
if inject_apipilot_pass2_oai:
user_ctx_oai_2.append(inject_apipilot_pass2_oai)
if original_user_context:
user_ctx_oai_2.append(original_user_context)
user_ctx_oai_2 = services_context._dedup_context_blocks_by_type(user_ctx_oai_2)
stripped_oai_2: list[str] = []
for p in user_ctx_oai_2:
sp = services_context._strip_internal_markers(p)
if sp:
stripped_oai_2.append(sp)
user_ctx_oai_2 = stripped_oai_2
eff_user_ctx_oai_2 = "\n\n".join(user_ctx_oai_2)
if settings.S2S_INJECT_PROFILE:
eff_inst_oai_2 = services_context._augment_instructions_with_profile(
req.instructions or "",
inject_profile_text,
inject_reports_text,
inject_summary_text,
inject_episode_text,
profile_obj=profile_obj,
)
else:
eff_inst_oai_2 = (req.instructions or "").strip()
sys_parts_oai_2: list[str] = []
instr_oai_2 = eff_inst_oai_2 or ((req.instructions or "").strip())
if instr_oai_2:
sys_parts_oai_2.append(instr_oai_2)
if eff_user_ctx_oai_2.strip():
sys_parts_oai_2.append(f"User context:\n{eff_user_ctx_oai_2.strip()}")
if inject_apipilot_pass2_oai:
sys_parts_oai_2.append(inject_apipilot_pass2_oai)
if req.language.strip():
sys_parts_oai_2.append(
"Reply only with the final assistant answer text. "
f"Keep the answer natural and concise in this language: {req.language.strip()}."
)
else:
sys_parts_oai_2.append(
"Reply only with the final assistant answer text. Keep the answer natural and concise."
)
system_msg_oai_2 = "\n\n".join(sys_parts_oai_2)
body_oai_2 = {
"model": settings.OPENAI_REASONING_MODEL,
"messages": [
{"role": "system", "content": system_msg_oai_2},
{"role": "user", "content": transcript},
],
}
if settings.S2S_DEBUG_PROMPT:
app_logging.logger.info(
"[DEBUG-PROMPT][REASONING → OPENAI PASS-2] FULL SYSTEM message (len=%d):\n%s\n",
len(system_msg_oai_2),
system_msg_oai_2,
)
try:
async with httpx.AsyncClient(timeout=30.0) as http_oai2:
resp_oai_2 = await http_oai2.post(
f"{settings.OPENAI_BASE_URL}/chat/completions",
headers=_openai_headers(),
json=body_oai_2,
)
except httpx.RequestError as exc:
app_logging.logger.warning("[EXT][OPENAI PASS-2] unreachable: %r", exc)
raise HTTPException( # noqa: B904
status_code=502,
detail="No se puede conectar con el servicio de razonamiento de OpenAI (segunda pasada).",
)
if resp_oai_2.status_code != 200:
app_logging.logger.warning(
"[EXT][OPENAI PASS-2] failed %s: %s", resp_oai_2.status_code, resp_oai_2.text[:400]
)
raise HTTPException(
status_code=502,
detail=f"La segunda pasada del razonamiento de OpenAI falló ({resp_oai_2.status_code}).",
)
try:
payload_oai_2 = resp_oai_2.json()
except Exception:
app_logging.logger.warning("[EXT][OPENAI PASS-2] invalid JSON: %s", resp_oai_2.text[:400])
raise HTTPException( # noqa: B904
status_code=502,
detail="El servicio de razonamiento de OpenAI devolvió un JSON no válido (segunda pasada).",
)
final_oai_ans = ""
choices_oai_2 = payload_oai_2.get("choices")
if isinstance(choices_oai_2, list) and choices_oai_2:
msg_oai_2 = choices_oai_2[0].get("message") if isinstance(choices_oai_2[0], dict) else None
if isinstance(msg_oai_2, dict):
content_oai_2 = msg_oai_2.get("content")
if isinstance(content_oai_2, str):
final_oai_ans = content_oai_2.strip()
usage_oai_2 = payload_oai_2.get("usage")
if not final_oai_ans:
# Last-resort fallback: raw tool-result text.
app_logging.logger.warning("[EXT][OPENAI PASS-2] empty answer, falling back to raw tool-result text")
final_oai_ans = (tool_text_oai or answer).strip()
final_out_oai: dict = {"answer": final_oai_ans}
if usage_oai_2:
final_out_oai["usage_pass2"] = usage_oai_2
final_out_oai["apipilot_invoked"] = {
"name": tool_name_p1_oai,
"success": not is_tool_err_oai,
"result_length": len(tool_text_oai),
}
app_logging.logger.info(
"[Vocero REASONING] engine=OPENAI PASS-2 transcript=%s finalAnswer=%s apipilot=%s",
transcript[:300],
final_oai_ans[:500],
json.dumps(final_out_oai["apipilot_invoked"], ensure_ascii=True),
)
return validated_json_response(final_out_oai, ReasoningResponse)
# Without a marker, return the original pass-1 answer.
return validated_json_response({"answer": answer}, ReasoningResponse)
@@ -1,629 +0,0 @@
from __future__ import annotations
import json
import re
from typing import cast
from vocero_api.core import logging as app_logging
# ---------------------------------------------------------------------------
# APIPILOT — Reasoning engine integration (two passes)
# ---------------------------------------------------------------------------
# Deduplication/start/end markers used by _classify_context_block to avoid
# injecting a block already supplied by the client or found in base_instructions.
APIPILOT_PASS1_LIST_MARKER = "<<<APIPILOT_TOOLS_LIST_BEGIN>>>"
APIPILOT_PASS2_RESULT_MARKER = "<<<APIPILOT_RESULT:"
# Flow:
# PASS 1: inject {name,description,required,parameters} and marker rules.
# The model matches descriptions and emits the CALL marker.
# PASS 2: capture the marker, execute through S2S_APIPILOT_TOOLS_EXECUTE_URL,
# inject the result text, and request a natural spoken response.
# ---------------------------------------------------------------------------
_APIPILOT_CALL_MARKER_RE = re.compile(
r"[<]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[>]{1,3}",
re.IGNORECASE | re.DOTALL,
)
# 2026-09-25: observed inverted marker (incorrect LLM syntax):
# >>>APIPILOT_CALL:NAME=tool,ARGUMENTS=json<<<
_APIPILOT_CALL_MARKER_INVERTED_RE = re.compile(
r"[>]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[<]{1,3}",
re.IGNORECASE | re.DOTALL,
)
def _apipilot_parse_call_marker(answer_text: str, *, defaults: dict | None = None) -> tuple[str | None, dict | None]:
"""Parse the mandatory CALL marker emitted by the LLM in pass 1.
Returns (tool_name, arguments_dict), or (None, None) when no marker exists.
Supports:
(1) ARGUMENTS={"key":"val"}, valid JSON.
(2) ARGUMENTS={}, a valid empty object.
(3) ARGUMENTS= without braces, observed on realtime WebSocket sessions.
(4) key=val,key2=val2 strings through a manual parsing fallback.
(5) After parsing, fill missing required arguments from defaults such as
phone and episode_id.
"""
if not isinstance(answer_text, str) or not answer_text.strip():
app_logging.logger.info("[APIPILOT-CALL-NAME] USE=PARSE_EMPTY_INPUT name=None markerFound=N")
return None, None
m = _APIPILOT_CALL_MARKER_RE.search(answer_text.strip())
inverted = False
if not m:
m = _APIPILOT_CALL_MARKER_INVERTED_RE.search(answer_text.strip())
inverted = bool(m)
if not m:
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=PARSE_NO_MATCH name=None markerFound=N textHead=%r", answer_text.strip()[:80]
)
return None, None
name_raw = m.group(1).strip()
args_raw = (m.group(2) if m.lastindex and m.lastindex >= 2 and isinstance(m.group(2), str) else "").strip()
if not name_raw:
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=PARSE_EMPTY_NAME name=None markerFound=Y argsRawHead=%r inverted=%s",
args_raw[:60],
("Y" if inverted else "N"),
)
return None, None
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=PARSE_MARKER_OK name=%r argsRawLen=%d markerFound=Y inverted=%s",
name_raw,
len(args_raw),
("Y" if inverted else "N"),
)
args: dict = {}
# Parse args_raw into a dict, tolerating common formatting variations.
if args_raw == "" or args_raw in {"{}", "null", "None"}:
args = {}
else:
# Attempt 1: standard JSON, with or without braces.
_candidate = args_raw
try:
args = json.loads(_candidate)
except Exception:
try:
# Attempt 2: supply missing outer braces.
args = json.loads("{" + _candidate + "}")
except Exception:
try:
# Attempt 3: replace single quotes with double quotes.
args = json.loads(_candidate.replace("'", '"'))
except Exception:
try:
# Attempt 4: supply braces and replace single quotes.
args = json.loads("{" + _candidate.replace("'", '"') + "}")
except Exception:
# Manual fallback 5: equals signs and commas.
# Example: args_raw="phone=+56,name=Mario" → dict.
args = {}
for piece in args_raw.split(","):
kv = piece.strip()
if not kv or "=" not in kv:
continue
k, v = kv.split("=", 1)
k = k.strip().strip('"').strip("'")
v = v.strip().strip('"').strip("'")
if k:
args[k] = v
if not isinstance(args, dict):
args = {}
# Stage 2: fill missing arguments from defaults (phone, etc.).
if isinstance(defaults, dict) and defaults:
for dk, dv in defaults.items():
if dk not in args or args[dk] in (None, "", []):
if dv not in (None, "", []):
args[dk] = dv
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=PARSE_RETURN_FINAL name=%r finalArgsKeys=%s defaultsApplied=%s",
name_raw,
sorted(list(args.keys())),
("Y" if isinstance(defaults, dict) and defaults else "N"),
)
return name_raw, args
def _resolve_apipilot_reasoning_context_block(
tools_list: list[dict] | None,
*,
call_result: dict | None = None,
context_defaults: dict | None = None,
) -> str:
"""Build the ApiPilot context block for the reasoning prompt.
With call_result=None, pass 1 supplies the tool list and marker syntax
the model must emit when a description matches.
With call_result={'name':str, 'text':str, 'error':bool}, pass 2 injects the
execution result and requests a final spoken/text response for the user.
The optional context_defaults dict adds known default values to pass 1.
The model must use these exact values for required arguments rather than
inventing them. Usually contains {"phone": "+56..."} from inject_phone.
"""
# Pass 2: the tool result is already available.
if isinstance(call_result, dict) and call_result.get("name"):
name = call_result.get("name") or "?"
text = (call_result.get("text") if isinstance(call_result.get("text"), str) else "") or ""
is_err = bool(call_result.get("error"))
status_label = "ERROR" if is_err else "OK"
title = f"<<<APIPILOT_RESULT:STATUS={status_label},NAME={name}>>>"
trailer = (
"Now reinterpret THE RESULT TEXT (above) in clear, concise natural language, "
"exclusively in the user's language (language field; Spanish by default). "
"Do not mention the internal tool or technical details. "
"Respond ONLY with the final content intended for the user."
)
return (
f"{APIPILOT_PASS2_RESULT_MARKER}PASS2>>>\n"
"### Invoked ApiPilot tool result\n"
f"{title}\n"
f"\n```tool-output\n"
f"{text.rstrip() if text.strip() else '(empty output)'}\n"
f"```\n\n"
f"{trailer}\n"
)
# Pass 1: available tools and marker rules.
tools = tools_list if isinstance(tools_list, list) else []
# Limit to 100 tools, although the guide specifies at most 50 for the demo.
tools_cap = tools[:100]
if not tools_cap:
return ""
entries: list[str] = []
for t in tools_cap:
if not isinstance(t, dict):
continue
name = t.get("name") if isinstance(t.get("name"), str) else ""
if not name:
continue
desc = cast(str, t.get("description")) if isinstance(t.get("description"), str) else ""
params = t.get("parameters") if isinstance(t.get("parameters"), dict) else None
requires_auth = bool(t.get("requires_auth"))
required_fields: list[str] = []
if isinstance(params, dict) and isinstance(params.get("required"), list):
required_fields = [str(x) for x in cast(list, params.get("required")) if isinstance(x, str)]
# Limit descriptions to about 500 characters to control prompt size.
desc_cut = desc[:500].rstrip()
if len(desc) > 500:
desc_cut += "…"
snippet = f"- name: {name}\n requires_auth: {('true' if requires_auth else 'false')}"
if required_fields:
snippet += f"\n required_parameters: [{', '.join(required_fields[:40])}]"
if desc_cut:
snippet += f"\n description: {desc_cut}"
entries.append(snippet)
if not entries:
return ""
rule = (
f"{APIPILOT_PASS1_LIST_MARKER}\n"
"### Available dynamic tools (ApiPilot AquaPortal)\n"
"\n"
"Each tool has a `name` field (a unique identifier: do NOT modify it, "
"use camelCase, or translate it) and a `description` describing its use case.\n"
"\n"
"MANDATORY EXECUTION RULES:\n"
"1. If the USER REQUEST (transcript) clearly matches the `description` of "
"EXACTLY one tool below, emit ONLY one line as the FIRST line of YOUR RESPONSE, "
"with this exact syntax:\n"
' <<<APIPILOT_CALL:NAME=tool_name,ARGUMENTS={"parameter1":"value1","parameter2":"value2"}>>>\n'
"2. ARGUMENTS={…} MUST be valid JSON (double quotes, commas, no comments). "
"Include ONLY fields listed in `required_parameters:`, if present.\n"
"3. Add NO text after the marker line: no explanations or previews. The engine "
"will execute the tool and supply its result in a second call.\n"
"4. If NO description matches, emit NO marker and respond normally in Spanish.\n"
"5. If multiple descriptions match, choose the most specific one and use the marker.\n"
"6. 🔴 EXCLUSIVE RESPONSE MODE (HIGHEST priority, mandatory): when using a CALL "
"marker, YOUR ENTIRE RESPONSE MUST CONTAIN ONLY THE MARKER LINE. "
"Write NOTHING BEFORE it: no 'Claro que sí', 'Voy a revisar', "
"'Voy a generar tu briefing', greeting, or transition. "
"Write NOTHING AFTER it: no comments, previews, or explanations. "
"Exactly one line containing the marker. Additional text prevents the engine "
"from recognizing the marker and executing the tool.\n"
"8. INCORRECT examples (NEVER do this):\n"
" 'Claro que sí, ahora ejecuto'\n"
" 'Voy a generar tu briefing ejecutivo personal.'\n"
" 'Claro que sí.\\n<<<APIPILOT_CALL:...>>>'\n"
" '<<<APIPILOT_CALL:...>>>\\nAhora proceso el resultado.'\n"
"9. 🔴 TTS ANTI-LEAK RULES (mandatory, HIGHEST audio priority):\n"
" <<<APIPILOT_CALL:...>>> IS AN INTERNAL SYSTEM SIGNAL and MUST NEVER be spoken,\n"
" read aloud, or synthesized by TTS. When emitting the marker:\n"
" - INTERNAL REASONING (text/transcript) MUST contain the exact marker line.\n"
" - FINAL TTS AUDIO heard by the user MUST contain ONLY this placeholder:\n"
' "Voy a consultar tus datos, un momento por favor."\n'
" - NEVER speak any of these technical terms in final TTS audio:\n"
" APIPILOT, CALL, ARGUMENTS, tool, herramienta, ejecutar, function, function call,\n"
" marcador, marker, even to explain or describe the call.\n"
"\n"
)
# 2026-09-25 known defaults: inject values already resolved from the URL or
# profile, such as phoneNumber, so tool arguments use those exact values.
if isinstance(context_defaults, dict) and context_defaults:
defaults_lines = []
for dk, dv in context_defaults.items():
if dv in (None, "", []):
continue
if isinstance(dv, (dict, list)):
try:
dv_repr = json.dumps(dv, ensure_ascii=False)
except Exception:
dv_repr = str(dv)
else:
dv_repr = str(dv)
defaults_lines.append(f'- {dk} = "{dv_repr}"')
if defaults_lines:
rule += (
"KNOWN DEFAULT VALUES (use these exact values in ARGUMENTS={…} when "
"the corresponding tool lists these names in required_parameters; "
"do not invent, translate, or normalize them):\n"
f"{chr(10).join(defaults_lines)}\n"
"\n"
)
rule += f"Tool list (name / description):\n{chr(10).join(entries)}\n<<<APIPILOT_TOOLS_LIST_END>>>"
return rule
def _sanitize_pass2_internal_context(text: str) -> str:
"""Lightly sanitize the pass-2 block injected into the upstream model.
Unlike _sanitize_text_strip_apipilot_markers, preserve the pass-2 wrapper:
the model needs it to recognize tool data. Limit sanitation to filesystem
path leaks, fragmented marker tokens, and contextual phone leaks.
"""
if not isinstance(text, str) or not text.strip():
return text
# Block 3: Unix/Windows/TRAE path leaks, matching the user-output sanitizer.
import re as _re_pass2
text = _re_pass2.sub(
r"(?i)(?:/[A-Za-z0-9_.\-@~]+){2,}/(?:\.trae[^\s\"']*|workspaceStorage[^\s\"']*|User[^\s\"']*|home[^\s\"']*)",
" ",
text,
)
text = _re_pass2.sub(r"(?i)(?:[A-Z]:)?[\\/](?:Users|home|root|tmp|var|mnt|opt|srv)[\\/][^\s\"']{5,}", " ", text)
text = _re_pass2.sub(r"(?i)(?:\.trae-server|\.trae[^\s\"']{0,40}|workspaceStorage[0-9a-f]+)", " ", text)
text = _re_pass2.sub(r"\s{2,}", " ", text)
# Block 4: fragmented marker leaks, tier 4.
tier4_out_parts: list[str] = []
for _line in text.splitlines(keepends=True):
_orig = _line
_low = _line.lower()
if "apipilot" in _low and (
("call" in _low and "name=" in _low) or ("arguments=" in _low) or ("result" in _low and "pass" in _low)
):
if "<<<" in _line or ">>>" in _line:
_line = _re_pass2.sub(
r"[<]{1,3}\s*APIPILOT_(?:CALL|RESULT)[^<>\n]{0,500}[>]{1,3}",
" ",
_line,
flags=_re_pass2.IGNORECASE,
)
if "apipilot" in _line.lower():
_line = _re_pass2.sub(
r"(?i)\bAPIPILOT(?:_CALL|_RESULT)?\b",
" ",
_line,
)
_line = _re_pass2.sub(
r"(?i)\b(?:NAME|ARGUMENTS|STATUS|REV|PASS2)\s*[:=]",
" ",
_line,
)
tier4_out_parts.append(_line if _line.strip() else _orig)
text = "".join(tier4_out_parts)
text = _re_pass2.sub(r"\s{2,}", " ", text)
return text.strip() if isinstance(text, str) else text
def _sanitize_text_strip_apipilot_markers(
text: str,
*,
placeholder: str | None = None,
) -> str:
"""Remove all internal ApiPilot markers from user-visible chat and TTS text.
Replace pass-1 CALL markers with a Spanish placeholder. Silently remove
pass-2 RESULT markers, their heading, tool-output block and instruction
trailer. These server-only signals must never reach the user.
Args:
text: Text to sanitize.
placeholder: Optional replacement; defaults to the Spanish UI/voice message.
"""
if not isinstance(text, str) or not text:
return text
if placeholder is None:
placeholder = "Voy a consultar tus datos, un momento por favor."
# ================================================================
# Block 1: silently remove pass-2 RESULT markers and wrappers from TTS/UI.
# ================================================================
# (1a) Full pass-2 wrapper first: marker, heading, status, output, trailer.
pass2_full_block_pattern = re.compile(
r"[ \t]*(?:\r?\n|\r)?[ \t]*"
r"<<<APIPILOT_RESULT:PASS2>>>[ \t]*(?:\r?\n|\r)+"
r"###[ \t]+(?:Resultado de la herramienta ApiPilot invocada|Invoked ApiPilot tool result)[ \t]*(?:\r?\n|\r)+"
r"<<<APIPILOT_RESULT:STATUS=[A-Za-z_]+,NAME=[A-Za-z0-9_.-]+>>>[ \t]*(?:\r?\n|\r)*"
r"(?:\r?\n|\r)?"
r"```tool-output[ \t]*(?:\r?\n|\r)?"
r"(?P<tool_body>.*?)"
r"(?:\r?\n|\r)?```[ \t]*(?:\r?\n|\r){0,3}"
r"(?:"
r"(?:Ahora debes reinterpretar EL TEXTO RESULTADO|Now reinterpret THE RESULT TEXT)[\s\S]{0,1200}?"
r"(?:\r?\n|\r){0,2}"
r")?"
r"[ \t]*",
re.IGNORECASE | re.DOTALL,
)
text = pass2_full_block_pattern.sub("", text)
# (1b) Standalone PASS2 markers when the wrapper is incomplete or fragmented.
pass2_marker_pattern = re.compile(
r"[ \t]*(?:\r?\n|\r)?[ \t]*<<<APIPILOT_RESULT(?::PASS2|:STATUS=[A-Za-z_]+,NAME=[A-Za-z0-9_.-]+)?>>>[ \t]*(?:\r?\n|\r)?[ \t]*",
re.IGNORECASE,
)
text = pass2_marker_pattern.sub("", text)
# (1c) Residual Markdown heading after the marker has been removed.
pass2_title_pattern = re.compile(
r"[ \t]*(?:\r?\n|\r)?[ \t]*###[ \t]+(?:Resultado de la herramienta ApiPilot invocada|Invoked ApiPilot tool result)[ \t]*(?:\r?\n|\r)?[ \t]*",
re.IGNORECASE,
)
text = pass2_title_pattern.sub("", text)
# (1d) Standalone tool-output blocks without a PASS2 wrapper.
pass2_codeblock_pattern = re.compile(
r"```tool-output[ \t]*(?:\r?\n|\r)?.*?(?:\r?\n|\r)?```",
re.IGNORECASE | re.DOTALL,
)
text = pass2_codeblock_pattern.sub("", text)
# (1e) Residual instruction trailers missed by the full-wrapper pattern.
pass2_trailer_pattern = re.compile(
r"(?:Ahora debes reinterpretar EL TEXTO RESULTADO[\s\S]{0,1200}?contenido final que le quieres transmitir al usuario|Now reinterpret THE RESULT TEXT[\s\S]{0,1200}?final content intended for the user)\.",
re.IGNORECASE | re.DOTALL,
)
text = pass2_trailer_pattern.sub("", text)
# Broader fallback also handles trailers truncated before their closing text.
pass2_trailer_fallback = re.compile(
r"(?:Ahora debes reinterpretar[\s\S]{0,1500}?(?:al usuario\.|$)|Now reinterpret THE RESULT TEXT[\s\S]{0,1500}?(?:for the user\.|$))",
re.IGNORECASE | re.DOTALL,
)
text = pass2_trailer_fallback.sub("", text)
# Handle residual middle fragments after the trailer's opening was removed.
pass2_trailer_broad = re.compile(
r"(?:en lenguaje natural[\s\S]{0,800}?contenido final que le quieres transmitir al usuario|in clear, concise natural language[\s\S]{0,800}?final content intended for the user)\.?",
re.IGNORECASE | re.DOTALL,
)
text = pass2_trailer_broad.sub("", text)
# ================================================================
# Block 2: pass-1 CALL marker replacement with a Spanish placeholder.
# ================================================================
marker_full_pattern = re.compile(
r"<<<APIPILOT_CALL:NAME=[A-Za-z0-9_.-]+,ARGUMENTS=.*?>>>",
re.IGNORECASE | re.DOTALL,
)
line_wrapped_pattern = re.compile(
r"[ \t]*(?:\r?\n|\r)?[ \t]*<<<APIPILOT_CALL:NAME=[A-Za-z0-9_.-]+,ARGUMENTS=.*?>>>[ \t]*(?:\r?\n|\r)?[ \t]*",
re.IGNORECASE | re.DOTALL,
)
def _replacement_full(_m: re.Match) -> str:
"""Replace standalone or inline markers with the spoken placeholder."""
return placeholder
text = line_wrapped_pattern.sub(_replacement_full, text)
text = marker_full_pattern.sub(_replacement_full, text)
# Remove truncated CALL markers such as <<<APIPILOT_CALL:NAME=tool,ARGS….
trunc_call_pattern = re.compile(
r"<<<APIPILOT_CALL(?::NAME=[A-Za-z0-9_.-]+,?[^>\n]{0,500}?)?>>*",
re.IGNORECASE,
)
text = trunc_call_pattern.sub(_replacement_full, text)
# Make closing brackets optional to remove RESULT markers truncated by TTS,
# such as <<<APIPILOT_RESULT:STATUS=OK,NAM without closing >>>.
trunc_result_pattern = re.compile(
r"<<<APIPILOT_RESULT(?::[A-Za-z0-9_=,:.\- ]{0,800}?)?>?>?>?",
re.IGNORECASE,
)
text = trunc_result_pattern.sub("", text)
# Remove remaining APIPILOT_RESULT fragments after partial sanitation.
trunc_result_residual = re.compile(
r"<{0,3}APIPILOT_RESULT(?::[^\s<]{0,800})?<{0,3}",
re.IGNORECASE,
)
text = trunc_result_residual.sub("", text)
# Collapse whitespace left by pass-2 block removal.
text = re.sub(r"[ \t]{2,}", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
# Remove residual trailer fragments and empty parentheses before TTS.
text = re.sub(r"\s*\((?:arriba|above)\)\s*", " ", text, flags=re.IGNORECASE)
text = re.sub(r"\(\s*\)", "", text)
# ================================================================
# Block 3: remove filesystem paths and host information leaked into TTS.
# Match long absolute Unix/Windows/mixed paths and known workspace tokens.
# ================================================================
# (3a) Absolute Unix paths and nested paths with multiple separators.
unix_path_pattern = re.compile(
r"\b(?:/(?:home|tmp|var|root|opt|mnt|media|srv|proc|sys|etc|usr|Users|trae)[/\w@.+\-:#$%^&()[\]{}!~=,]*){2,}[A-Za-z0-9_./\-+@#$%^&()[\]{}!~=,:]*\.(?:txt|md|log|json|csv|pdf|doc|docx|py|js|ts|html|css|sh|yaml|yml|env|conf|cfg|ini|mp3|wav|ogg|png|jpg|jpeg|gif)\b"
r"|"
# Broad fallback for long absolute Unix paths under known directories,
# including paths without extensions; avoid short URLs such as /api/v1.
r"(?:^|\s+|\(|[,.;:!?])(/(?:home|tmp|var|root|opt|mnt|media|srv|proc|sys|etc|usr|Users|trae)[/A-Za-z0-9_\-+@#$%^&()[\]{}!~=,.]{10,})(?=$|\s|\)|[,.;:!?])",
re.IGNORECASE,
)
text = unix_path_pattern.sub("", text)
# (3b) Windows paths with single/double backslashes and mixed separators.
win_path_pattern = re.compile(
r"(?:^|\s|\(|[,.;:])"
r"([A-Za-z]:[\\/]"
r"|(?:[\\/]{1,2}(?:home|Users|Windows|Program|Program\sFiles|ProgramData|Users|trae|AppData)[\\/])"
r"|(?:[\\/]{1,2}\.(?:trae|trae-server|vscode|config|cache)[\\/])"
r")"
r"[A-Za-z0-9_@.+\-:#$%^&()[\]{}!~=,\\/\s]{10,}"
r"(?:\.(?:txt|md|log|json|csv|pdf|doc|docx|py|js|ts|html|css|sh|yaml|yml|env|conf|cfg|ini|mp3|wav|ogg|png|jpg|jpeg|gif))?"
r"(?=$|\s|\)|[,.;:!?])",
re.IGNORECASE,
)
text = win_path_pattern.sub("", text)
# (3c) TRAE workspace tokens, including fragments without complete paths.
trae_leak_tokens = re.compile(
r"\b(?:workspaceStorage|long-text|\.trae-server|\.trae|ckg_server|ai-agent)\b[^\s()'\"`,.]{0,80}",
re.IGNORECASE,
)
text = trae_leak_tokens.sub("", text)
# (3d) Remove long tokens containing mixed separators or many path segments.
mixed_path_pattern = re.compile(
r"(?=\S{0,200})"
r"(?=\S*(?:[/\\].*){5,})"
r"(?:\S{10,})",
re.IGNORECASE,
)
def _mixed_safe(m: re.Match[str]) -> str:
s = m.group(0)
has_slash = "/" in s
has_back = "\\" in s
if (has_slash and has_back) or s.count("/") + s.count("\\") >= 6:
return ""
return s
text = mixed_path_pattern.sub(_mixed_safe, text)
# ================================================================
# Block 4: remove fragmented APIPILOT CALL text from TTS. The upstream
# pipeline (start_pipeline_rag / Qwen3-TTS) can remove <<< >>> and read:
# "APIPILOT CALL name get_user_briefing arguments phone +56912345678"
# The bracketed patterns cannot catch this, so use contextual fallback tiers.
# ================================================================
# Tier 1: full normalized form without braces, through the sentence ending.
call_normalized_full = re.compile(
r"(?:^|\s+|\(|[,.;:!?\"\'])\s*"
r"(?:"
r"APIPILOT[\s_\-]+CALL\s+(?:marker\s+)?(?:de\s+)?(?:señal\s+)?(?:herramienta\s+)?(?:funci[oó]n\s+)?"
r"|marcador\s+APIPILOT(?:\s+CALL)?\s*"
r"|señal\s+de\s+llamada\s+APIPILOT\s*"
r"|llamada\s+(?:a\s+)?(?:la\s+)?(?:herramienta|funci[oó]n)\s+APIPILOT\s*"
r")"
r"(?:\s*(?:con\s+)?(?:identificador|ID|name|nombre)\s*[:=]?\s*[A-Za-z0-9_.-]+)?"
r"(?:\s*(?:y\s+)?(?:con\s+)?(?:argumentos|ARGUMENTS|params|par[áa]metros)\s*[:=]?\s*(?:\{[^{}]{0,800}\}|[\"'][^\"']{0,200}[\"']|[^\s.!,?;]{3,100}(?:\s*[,:]\s*[^\s.!,?;]{3,100})*\s*)?)?"
r"(?:\s*(?:phone|tel[ée]fono|correo|email)\s*[:=]?\s*[+0-9A-Za-z._@\-]{6,50})?"
r"(?=$|\s+|\)|[,.;:!?\"\'])",
re.IGNORECASE,
)
text = call_normalized_full.sub(placeholder, text)
# Tier 2: shortened forms with APIPILOT_CALL or adjacent APIPILOT and CALL.
apipilot_call_token = re.compile(
r"(?:^|\s|\(|[,.;:!?\"\'])\s*"
r"APIPILOT(?:[\s_\-]+CALL|_CALL|\s+llamada|\s+marker)"
r"(?:[^.!,?;\n]{0,600})?"
r"(?=$|\s|\)|[,.;:!?\"\'])",
re.IGNORECASE,
)
text = apipilot_call_token.sub(placeholder, text)
# Tier 3: standalone service tokens in call/name/argument contexts.
service_token_context = re.compile(
r"(?:"
r"ARGUMENTS\s*[:=]\s*(?:\{[^{}]{0,800}\}|[^\s,.;!?]{3,100})"
r"|\barguments\s+(?:de\s+)?(?:la\s+)?(?:llamada|herramienta|funci[oó]n)\b[^\n.]{0,200}"
r")",
re.IGNORECASE,
)
text = service_token_context.sub("", text)
# Tier 4: remove sentences containing APIPILOT with technical call context.
def _apipilot_sentence(m: re.Match[str]) -> str:
sentence = m.group(0)
lowered = sentence.lower()
# Preserve a sentence already containing the Spanish CALL placeholder,
# including any natural text surrounding it.
if "voy a consultar tus datos" in lowered:
return sentence
ctx_kw = (
"name",
"argument",
"arg",
"herramienta",
"tool",
"funci",
"phone",
"tel",
"+56",
"+44",
"marker",
"call",
"llamada",
"señal",
"marcador",
)
if any(k in lowered for k in ctx_kw) and "apipilot" in lowered:
return ""
return sentence
catchall_sentence = re.compile(
r"[^.?!;\n]{0,600}APIPILOT[^.?!;\n]{0,600}[.?!;\n]{0,1}",
re.IGNORECASE,
)
text = catchall_sentence.sub(_apipilot_sentence, text)
# Tier 5: replace any remaining APIPILOT CALL tokens, even without context.
final_apipilot_leak = re.compile(
r"\bAPIPILOT[\s_\-]+CALL\b|\bAPIPILOT_CALL\b",
re.IGNORECASE,
)
text = final_apipilot_leak.sub(placeholder, text)
# Tier 6: remove residual fake/real phone numbers from internal call text.
# Match compact and spaced forms to tolerate TTS normalization.
fake_phone_pattern = re.compile(
r"("
# Compact forms without spaces:
r"\+56912345678"
r"|\+1234567890"
r"|\+447432477433"
r"|56912345678"
r"|1234567890"
r"|447432477433"
# Spaced forms with punctuation:
r"|\+?\s*56\s*[\s\-()]?9\s*[\s\-()]?1\s*[\s\-()]?2\s*[\s\-()]?3\s*[\s\-()]?4\s*[\s\-()]?5\s*[\s\-()]?6\s*[\s\-()]?7\s*[\s\-()]?8"
r"|\+?\s*1\s*[\s\-()]?2\s*[\s\-()]?3\s*[\s\-()]?4\s*[\s\-()]?5\s*[\s\-()]?6\s*[\s\-()]?7\s*[\s\-()]?8\s*[\s\-()]?9\s*[\s\-()]?0"
r"|\+?\s*44\s*[\s\-()]?7\s*[\s\-()]?4\s*[\s\-()]?3\s*[\s\-()]?2\s*[\s\-()]?4\s*[\s\-()]?7\s*[\s\-()]?7\s*[\s\-()]?4\s*[\s\-()]?3\s*[\s\-()]?3"
# Generic labeled forms (English or Spanish phone labels):
r"|phone\s*[:=]\s*[+()\-\s]*[0-9][+()\-\s0-9]{6,}"
r"|tel(?:éfono|efono)?\s*[:=]\s*[+()\-\s]*[0-9][+()\-\s0-9]{6,}"
r")",
re.IGNORECASE,
)
# Strip numbers only from lines with technical call context or the Spanish
# placeholder, indicating that the number belongs to a replaced CALL marker.
lines = text.splitlines(keepends=True)
_phone_ctx = (
"apipilot",
"call",
"arguments",
"marker",
"herramienta",
"funcion",
"función",
"ejecutar",
"llamada",
"marcador",
"señal",
"voy a consultar tus datos",
)
def _phone_line_safe(line: str) -> str:
lowered = line.lower()
has_ctx = any(c in lowered for c in _phone_ctx)
if has_ctx:
# Remove phone numbers in this line (strip also surrounding space)
return fake_phone_pattern.sub(" ", line)
return line
text = "".join(_phone_line_safe(line) for line in lines)
# Collapse whitespace left by marker and path removal.
text = re.sub(r"[ \t]{2,}", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
+5 -4
View File
@@ -144,10 +144,11 @@ Markdown replies, JSON/TXT exports, optional browser wake word, TALK/TALKFIRST
startup modes, and optional backend features are retained. Camera controls and
quick actions remain absent, matching their disabled state in the old UI.
Native speech stays at the current project's 16 kHz PCM rate. The backend
continues to own VAD, transcription, model inference, synthesis, upstream
credentials, and ApiPilot execution. Browser `SpeechSynthesis` is used only
when the configured external reasoning service returns an answer.
Native speech stays at the current project's 16 kHz PCM rate. The speech service
owns VAD, transcription, model inference, and synthesis. The Vocero API owns
AquaPortal credentials and authenticated MCP discovery/execution. Browser
`SpeechSynthesis` is used only when the configured external reasoning service
returns an answer.
Session startup waits for audio initialization and `session.created`, then sends
`session.update` before microphone audio or the initial greeting. The speech
+23 -7
View File
@@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import App from "./App";
import { VoiceController } from "./realtime/controller";
const bootstrap = { theme: "white", s2sUrl: "ws://localhost:8420/v1/realtime", apipilot: true };
const bootstrap = { theme: "white", s2sUrl: "ws://localhost:8420/v1/realtime" };
beforeEach(() => {
window.history.replaceState({}, "", "/?debug=true&phoneNumber=1234");
vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(function (this: HTMLDialogElement) {
@@ -29,9 +29,13 @@ beforeEach(() => {
truncated: true,
note: "Lista abreviada",
},
"/api/apipilot/tools": {
success: true,
data: { tools: [{ name: "get_reports", description: "Consulta tus reportes", parameters: {} }] },
"/api/conversations": {
conversationId: "conversation-ana",
expiresIn: 3600,
profile: { id: "ana", name: "Ana Vargas" },
},
"/api/mcp/tools": {
tools: [{ name: "list_reports", description: "Consulta tus reportes", parameters: {}, requires_auth: true }],
},
};
return Promise.resolve(new Response(JSON.stringify(data[path] ?? {})));
@@ -150,7 +154,7 @@ describe("React application", () => {
await user.click(screen.getByRole("button", { name: "Vaciar" }));
expect(screen.getByRole("textbox")).toHaveValue("");
});
it("shows nested profile fields and discovers remote tools after bootstrap", async () => {
it("shows nested profile fields", async () => {
const user = userEvent.setup();
render(<App />);
await user.click(await screen.findByRole("button", { name: "Ver datos de Ana Vargas" }));
@@ -159,11 +163,23 @@ describe("React application", () => {
"mailto:ana@example.test",
);
await user.click(screen.getByRole("button", { name: "Cerrar" }));
await user.click(screen.getByRole("button", { name: "Herramientas" }));
});
it("discovers and displays MCP tools for the authenticated conversation", async () => {
vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ ...bootstrap, mcp: true })));
const user = userEvent.setup();
render(<App />);
await user.click(await screen.findByRole("button", { name: "Herramientas" }));
await waitFor(() => {
expect(screen.getByText("get_reports")).toBeInTheDocument();
expect(screen.getByText("list_reports")).toBeInTheDocument();
});
expect(screen.getByText("Consulta tus reportes")).toBeInTheDocument();
expect(vi.mocked(fetch).mock.calls.map(([path]) => path)).toEqual([
"/api/config",
"/api/conversations",
"/api/mcp/tools",
]);
const discovery = vi.mocked(fetch).mock.calls.find(([path]) => path === "/api/mcp/tools");
expect(new Headers(discovery?.[1]?.headers).get("X-Vocero-Conversation")).toBe("conversation-ana");
});
it("keeps debug controls out of the normal user screen", async () => {
window.history.replaceState({}, "", "/");
@@ -282,17 +282,16 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
Herramientas MCP cargadas
</span>
<span className="text-[13.5px] text-muted compact:text-[12.5px]">
Herramientas publicadas por el perfil remoto del agente.
Herramientas disponibles para esta conversación.
</span>
</div>
</div>
<p className="block text-[13px] leading-[1.4] text-faint compact:text-[12px]" role="status">
{state.toolsLoading
? "Cargando herramientas…"
: state.toolsError ||
(state.tools.length
? `${String(state.tools.length)} herramientas disponibles.`
: "No hay herramientas remotas disponibles.")}
: state.tools.length
? `${String(state.tools.length)} herramientas disponibles.`
: "No hay herramientas remotas disponibles."}
</p>
<div className="flex flex-col gap-2">
{state.tools.map((tool) => (
+1 -20
View File
@@ -1,13 +1,6 @@
import { z } from "zod";
import {
configSchema,
conversationSchema,
mcpToolsSchema,
recordSchema,
reportsSchema,
toolsEnvelopeSchema,
} from "./contracts";
import { configSchema, conversationSchema, mcpToolsSchema, recordSchema, reportsSchema } from "./contracts";
import type { Caller } from "./settings";
export class ApiError extends Error {
@@ -108,18 +101,6 @@ export function getReports(phone: string, signal: AbortSignal) {
const query = phone ? `?${new URLSearchParams({ phoneNumber: phone })}` : "";
return optionalRequest(`/api/user/reports${query}`, reportsSchema, signal);
}
export async function getTools(signal: AbortSignal, refresh = false) {
const result = await request(
`/api/apipilot/tools?include_unauthorized=true&refresh=${String(refresh)}`,
toolsEnvelopeSchema,
{ signal },
10000,
);
if (!result.success || !result.data) {
throw new Error("No se pudieron cargar las herramientas.");
}
return result.data.tools;
}
export function conversationHeaders(id: string, turn?: number): Record<string, string> {
return { "X-Vocero-Conversation": id, ...(turn === undefined ? {} : { "X-Vocero-Turn": String(turn) }) };
+3 -29
View File
@@ -1,4 +1,4 @@
import { recordSchema, type DynamicTool, type FunctionTool, type Profile, type ServerConfig } from "./contracts";
import { recordSchema, type FunctionTool, type Profile, type ServerConfig } from "./contracts";
import type { Settings } from "./settings";
export function profileName(profile: Profile | null): string {
@@ -43,35 +43,9 @@ export function profileContext(profile: Profile | null) {
export function ragContext(text: string) {
return text.trim() ? `<<<CUSTOM_RAG_BEGIN>>>\n${text.trim()}\n<<<CUSTOM_RAG_END>>>` : "";
}
export const TOOL_MARKER = "<<<APIPILOT_TOOLS_LIST_BEGIN>>>";
export function toolContext(tools: DynamicTool[]) {
if (!tools.length) {
return "";
}
const entries = tools.slice(0, 100).map((tool) => {
const required = Array.isArray(tool.parameters.required)
? tool.parameters.required.filter((key): key is string => typeof key === "string")
: [];
return `- name: ${tool.name}\n requires_auth: ${String(tool.requires_auth)}${required.length ? `\n required_parameters: [${required.slice(0, 40).join(", ")}]` : ""}\n description: ${tool.description.slice(0, 500)}`;
});
return `${TOOL_MARKER}\n### Available dynamic tools (ApiPilot AquaPortal)\nEach tool name is a unique identifier: DO NOT change, camelCase or translate it.\nMANDATORY EXECUTION RULES:\n1. If the user request matches a tool description, output ONLY one line as the FIRST line of your response:\n<<<APIPILOT_CALL:NAME=tool_name,ARGUMENTS={"parameter1":"value1"}>>>\n2. ARGUMENTS must be valid JSON. Include ONLY fields listed in required_parameters, if any.\n3. Do NOT add text after the marker. The engine executes the tool and supplies its result in a second call.\n4. If no description matches, respond normally in Spanish without a marker.\n5. If multiple descriptions match, choose the most specific.\n${entries.join("\n")}\n<<<APIPILOT_TOOLS_LIST_END>>>`;
}
export function buildInstructions(
config: ServerConfig,
settings: Settings,
profile: Profile | null,
rag: string,
tools: DynamicTool[],
) {
export function buildInstructions(config: ServerConfig, settings: Settings, profile: Profile | null, rag: string) {
const base = config.primaryPrompt || settings.instructions;
return [
base,
config.injectProfile ? profileContext(profile) : "",
ragContext(rag),
config.mcp || base.includes(TOOL_MARKER) ? "" : toolContext(tools),
]
.filter(Boolean)
.join("\n\n");
return [base, config.injectProfile ? profileContext(profile) : "", ragContext(rag)].filter(Boolean).join("\n\n");
}
export function speechInstructions(instructions: string, config: ServerConfig) {
return config.reasoningType === "OPENAI"
-6
View File
@@ -14,7 +14,6 @@ export const configSchema = z.object({
debugPrompt: z.boolean().default(false),
startSessionMode: z.string().default(""),
theme: z.string().default(""),
apipilot: z.boolean().default(false),
mcp: z.boolean().default(false),
});
export type ServerConfig = z.infer<typeof configSchema>;
@@ -50,11 +49,6 @@ export interface FunctionTool {
description: string;
parameters: Record<string, unknown>;
}
export const toolsEnvelopeSchema = z.object({
success: z.boolean(),
data: z.object({ tools: z.array(toolSchema), count: z.number().optional() }).nullish(),
error: z.unknown().optional(),
});
export const conversationSchema = z.object({
conversationId: z.string().min(1),
expiresIn: z.number(),
+3 -33
View File
@@ -11,7 +11,6 @@ import {
getMcpTools,
getProfile,
getReports,
getTools,
interruptConversation,
post,
reasoningSchema,
@@ -53,7 +52,6 @@ export interface AppState {
reports: Reports | null;
tools: DynamicTool[];
toolsLoading: boolean;
toolsError: string;
status: ConnectionStatus;
error: string;
notice: string;
@@ -102,7 +100,6 @@ export class VoiceController {
reports: null,
tools: [],
toolsLoading: false,
toolsError: "",
status: "idle",
error: "",
notice: "",
@@ -194,12 +191,7 @@ export class VoiceController {
}
})
.catch(() => undefined);
const tools = profile.then(async () => {
if (config.apipilot && !signal.aborted) {
await this.refreshTools(signal, !!this.state.profile);
}
});
await Promise.all([profile, reports, tools]);
await Promise.all([profile, reports]);
} catch (error) {
if (!signal.aborted) {
this.update({ loading: false, configError: `No se pudo cargar la configuración. ${errorMessage(error)}` });
@@ -212,7 +204,7 @@ export class VoiceController {
const version = ++this.conversationVersion;
let createdId: string | undefined;
this.releaseConversation();
this.update({ contextLoading: true, profile: null, reports: null, tools: [], toolsLoading: true, toolsError: "" });
this.update({ contextLoading: true, profile: null, reports: null, tools: [], toolsLoading: true });
try {
if (!this.caller.phoneNumber) {
throw new Error("Abre Vocero desde un enlace que incluya tu número de teléfono.");
@@ -247,30 +239,8 @@ export class VoiceController {
this.initialization?.abort();
void this.stop();
}
async refreshTools(signal: AbortSignal, refresh = false) {
this.update({ toolsLoading: true, toolsError: "" });
try {
const tools = this.state.config.mcp
? await getMcpTools(this.conversationId ?? "", signal)
: await getTools(signal, refresh);
if (!signal.aborted) {
this.update({ tools, toolsLoading: false });
this.pushSettings();
}
} catch (error) {
if (!signal.aborted) {
this.update({ toolsError: errorMessage(error), toolsLoading: false });
}
}
}
private instructions() {
return buildInstructions(
this.state.config,
this.state.settings,
this.state.profile,
this.state.rag,
this.state.tools,
);
return buildInstructions(this.state.config, this.state.settings, this.state.profile, this.state.rag);
}
private tools() {
return this.state.settings.webSearch && (this.state.config.search || this.state.settings.searchKey)