From 077bc0907e4f943ba48579dcd71490489c3952d8 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Fri, 12 Jun 2026 18:09:57 -0300 Subject: [PATCH 1/2] Add voice-gateway: full-duplex voice for any registered agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidecar daemon (Python, same pattern as wp-carpeta's bridge/) that puts Gemini Live in front of openclaWP's agenttic chat endpoint as a pure voice shell: native STT/TTS, turn-taking and barge-in stay in Gemini, while every domain request is forwarded through a single ask_agent tool to the registered agent — so memory, destructive-tool gating and transcripts stay in WordPress. - gateway.py: WS bridge browser⇄Gemini Live (BidiGenerateContent), async tool execution, sticky openclawp sessionId per voice session, per-session audio cost log, /healthz. - static/index.html: mic capture (PCM s16 16kHz), scheduled 24kHz playback with barge-in flush, live transcripts, text fallback. - README: setup, Caddy path-prefix proxy, launchd unit, wire protocol. Co-Authored-By: Claude Fable 5 --- voice-gateway/.env.example | 26 ++ voice-gateway/.gitignore | 3 + voice-gateway/README.md | 125 ++++++++++ voice-gateway/gateway.py | 425 ++++++++++++++++++++++++++++++++ voice-gateway/requirements.txt | 5 + voice-gateway/static/index.html | 191 ++++++++++++++ 6 files changed, 775 insertions(+) create mode 100644 voice-gateway/.env.example create mode 100644 voice-gateway/.gitignore create mode 100644 voice-gateway/README.md create mode 100644 voice-gateway/gateway.py create mode 100644 voice-gateway/requirements.txt create mode 100644 voice-gateway/static/index.html diff --git a/voice-gateway/.env.example b/voice-gateway/.env.example new file mode 100644 index 0000000..b41eab6 --- /dev/null +++ b/voice-gateway/.env.example @@ -0,0 +1,26 @@ +# openclaWP Voice Gateway — copy to .env and fill in. + +# Gemini Live (AI Studio key). GOOGLE_API_KEY works as fallback. +GEMINI_API_KEY= + +# WordPress site that hosts the agent (no trailing slash). +OPENCLAWP_VOICE_WP_BASE=https://example.com + +# Agent slug as registered with wp_register_agent(). e.g. carpeta-bot +OPENCLAWP_VOICE_AGENT= +# Human label spoken by the voice shell. e.g. Menta +OPENCLAWP_VOICE_AGENT_LABEL= + +# JSON file with {"user": "...", "app_password": "..."} — chmod 600. +# OPENCLAWP_VOICE_AUTH_FILE=~/.openclawp-voice/auth.json + +# Optional extra persona lines appended to the voice system instruction. +# OPENCLAWP_VOICE_PERSONA=Sos la voz de Menta, asistente del campo. Rioplatense seco. +# OPENCLAWP_VOICE_PERSONA_FILE= + +# Tuning (defaults shown). +# OPENCLAWP_VOICE_MODEL=gemini-3.1-flash-live-preview +# OPENCLAWP_VOICE_NAME=Puck +# OPENCLAWP_VOICE_TZ=America/Montevideo +# OPENCLAWP_VOICE_HOST=127.0.0.1 +# OPENCLAWP_VOICE_PORT=8766 diff --git a/voice-gateway/.gitignore b/voice-gateway/.gitignore new file mode 100644 index 0000000..67b5e38 --- /dev/null +++ b/voice-gateway/.gitignore @@ -0,0 +1,3 @@ +.venv/ +.env +__pycache__/ diff --git a/voice-gateway/README.md b/voice-gateway/README.md new file mode 100644 index 0000000..fcad274 --- /dev/null +++ b/voice-gateway/README.md @@ -0,0 +1,125 @@ +# openclaWP Voice Gateway + +Full-duplex voice for any agent registered with the Agents API on a WordPress +site running openclaWP. **Gemini Live is only the voice shell** (native +speech-to-text, text-to-speech, turn-taking, barge-in); the agent stays the +single brain. Every user request is forwarded through one tool — `ask_agent` — +to openclaWP's agenttic chat endpoint, so the agent's memory, tool gating +(destructive-action confirmations) and transcripts all stay in WordPress. + +```text +browser mic (PCM 16 kHz over WSS) ⇄ gateway.py ⇄ Gemini Live API + │ ask_agent(consulta) + ▼ + POST {site}/wp-json/openclawp/v1/agenttic/{agent} + (JSON-RPC `message/send`, app-password auth, sticky sessionId) +``` + +This is a sidecar daemon, not WordPress code — same pattern as wp-carpeta's +`bridge/`. It needs Python ≥ 3.10. + +## Setup + +```bash +cd voice-gateway +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt +cp .env.example .env # fill in key, site, agent slug +``` + +Create the auth file (an Application Password for a user allowed to chat with +the agent): + +```bash +mkdir -p ~/.openclawp-voice +cat > ~/.openclawp-voice/auth.json <<'EOF' +{ "user": "wpuser", "app_password": "xxxx xxxx xxxx xxxx xxxx xxxx" } +EOF +chmod 600 ~/.openclawp-voice/auth.json +``` + +Run: + +```bash +.venv/bin/python gateway.py +# → http://127.0.0.1:8766 (page + /ws/voice + /healthz) +``` + +`GET /healthz` reports missing config without burning a Gemini session. + +## Browser requirements + +`getUserMedia` needs a **secure origin** — serve through a TLS reverse proxy +(localhost is also fine for testing). The page computes the WS URL relative to +its own location, so serving under a path prefix works. + +### Caddy example (path prefix `/voz/`) + +```caddyfile +handle_path /voz/* { + reverse_proxy 127.0.0.1:8766 +} +``` + +### launchd example (macOS host) + +`~/Library/LaunchAgents/com.openclawp.voice-gateway.plist`: + +```xml + + + + Labelcom.openclawp.voice-gateway + ProgramArguments + /PATH/TO/voice-gateway/.venv/bin/python + /PATH/TO/voice-gateway/gateway.py + + WorkingDirectory/PATH/TO/voice-gateway + RunAtLoad + KeepAlive + StandardOutPath/tmp/openclawp-voice.log + StandardErrorPath/tmp/openclawp-voice.log + +``` + +```bash +launchctl load ~/Library/LaunchAgents/com.openclawp.voice-gateway.plist +``` + +## Configuration + +| Variable | Default | Meaning | +|---|---|---| +| `GEMINI_API_KEY` | — | AI Studio key (`GOOGLE_API_KEY` fallback) | +| `OPENCLAWP_VOICE_WP_BASE` | — | Site URL, no trailing slash | +| `OPENCLAWP_VOICE_AGENT` | — | Agent slug (`wp_register_agent`) | +| `OPENCLAWP_VOICE_AGENT_LABEL` | slug | Name the voice shell uses | +| `OPENCLAWP_VOICE_AUTH_FILE` | `~/.openclawp-voice/auth.json` | `{user, app_password}` JSON | +| `OPENCLAWP_VOICE_PERSONA` / `_FILE` | — | Extra persona lines for the voice shell | +| `OPENCLAWP_VOICE_MODEL` | `gemini-3.1-flash-live-preview` | Gemini Live model | +| `OPENCLAWP_VOICE_NAME` | `Puck` | Gemini prebuilt voice | +| `OPENCLAWP_VOICE_TZ` | `America/Montevideo` | Temporal-context timezone | +| `OPENCLAWP_VOICE_HOST` / `_PORT` | `127.0.0.1` / `8766` | Bind address | + +## Design notes + +- **One brain.** The voice model is instructed to forward *everything* + domain-related through `ask_agent` and never answer from its own knowledge. + Agent turns take seconds; the tool runs async (Gemini Live keeps the audio + channel open) and the shell verbalizes the wait. +- **Session continuity.** The first agent reply returns an openclawp + `sessionId`; the gateway pins it for the rest of the voice session, so the + agent has conversational memory and WP keeps the transcript. +- **Gated tools.** If the agent answers with a pending-confirmation message, + the shell reads it out loud and relays the user's spoken decision back + through `ask_agent`. The site-side decision flow is unchanged. +- **Costs.** Session audio seconds (in/out) are logged on close. + +## Browser wire protocol + +Upstream: `{"audio": ""}` or `{"text": "..."}`. +Downstream: `{"audio": ""}`, +`{"type": "user_transcript"|"agent_transcript", "text", "final"}`, +`{"type": "agent_thought", "text"}` (what was forwarded to the agent), +`{"type": "interrupted"}` (barge-in: flush playback), `{"type": "ready"}`, +`{"text": ""}`. diff --git a/voice-gateway/gateway.py b/voice-gateway/gateway.py new file mode 100644 index 0000000..90316cf --- /dev/null +++ b/voice-gateway/gateway.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +""" +openclaWP Voice Gateway — talk to any Agents API agent, full duplex. + +Architecture (one sentence): Gemini Live is the voice shell (native STT/TTS, +turn-taking, barge-in); the registered agent stays the only brain, reached +through a single tool (`ask_agent`) that forwards each request to openclaWP's +agenttic chat endpoint, so memory, tool gating and transcripts stay in WP. + + browser mic (PCM 16 kHz over WS) ⇄ this gateway ⇄ Gemini Live API + │ ask_agent(consulta) + ▼ + POST {WP_BASE}/wp-json/openclawp/v1/agenttic/{AGENT} + (JSON-RPC message/send, app-password auth, sticky sessionId) + +The browser protocol mirrors the proven laviere voice frontend: JSON frames +{audio: } / {text: } upstream; {audio: }, +{type: user_transcript|agent_transcript, text, final}, {type: interrupted} +downstream. + +Run: uvicorn is embedded — `python3 gateway.py` (see README.md). +""" + +import asyncio +import base64 +import json +import logging +import os +import time +import uuid +from datetime import datetime, timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +import httpx +import websockets +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +try: + from dotenv import load_dotenv + + load_dotenv(Path(__file__).parent / ".env") +except ImportError: + pass + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger("voice-gateway") + +# ── Config ──────────────────────────────────────────────────────────────────── + +GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY", "") +GEMINI_MODEL = os.environ.get("OPENCLAWP_VOICE_MODEL", "gemini-3.1-flash-live-preview") +GEMINI_VOICE = os.environ.get("OPENCLAWP_VOICE_NAME", "Puck") +GEMINI_WS_URL = ( + "wss://generativelanguage.googleapis.com/ws/" + "google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent" +) + +WP_BASE = os.environ.get("OPENCLAWP_VOICE_WP_BASE", "").rstrip("/") +AGENT_SLUG = os.environ.get("OPENCLAWP_VOICE_AGENT", "") +AGENT_LABEL = os.environ.get("OPENCLAWP_VOICE_AGENT_LABEL", AGENT_SLUG) +AUTH_FILE = Path( + os.environ.get("OPENCLAWP_VOICE_AUTH_FILE", str(Path.home() / ".openclawp-voice" / "auth.json")) +) +TIME_ZONE = os.environ.get("OPENCLAWP_VOICE_TZ", "America/Montevideo") +HOST = os.environ.get("OPENCLAWP_VOICE_HOST", "127.0.0.1") +PORT = int(os.environ.get("OPENCLAWP_VOICE_PORT", "8766")) + +# Extra persona text appended to the system instruction (or a file with it). +PERSONA = os.environ.get("OPENCLAWP_VOICE_PERSONA", "") +PERSONA_FILE = os.environ.get("OPENCLAWP_VOICE_PERSONA_FILE", "") +if not PERSONA and PERSONA_FILE and Path(PERSONA_FILE).exists(): + PERSONA = Path(PERSONA_FILE).read_text(encoding="utf-8") + + +def validate_config() -> list: + missing = [] + if not GEMINI_API_KEY: + missing.append("GEMINI_API_KEY") + if not WP_BASE: + missing.append("OPENCLAWP_VOICE_WP_BASE") + if not AGENT_SLUG: + missing.append("OPENCLAWP_VOICE_AGENT") + if not AUTH_FILE.exists(): + missing.append(f"auth file {AUTH_FILE} (JSON {{user, app_password}})") + return missing + + +def read_auth() -> dict: + return json.loads(AUTH_FILE.read_text(encoding="utf-8")) + + +# ── System instruction ──────────────────────────────────────────────────────── + + +def build_system_instruction() -> str: + base = f"""Sos la voz de {AGENT_LABEL}, un agente que vive en un sitio WordPress. +Tu único trabajo es ser sus oídos y su boca; el razonamiento lo hace el agente. + +Reglas estrictas: +1. Para CUALQUIER consulta, dato, registro o acción que pida el usuario, llamá la + herramienta `ask_agent` pasando el pedido completo y fiel del usuario. No + respondas de memoria ni inventes datos: si la pregunta es sobre el dominio + del agente, va por `ask_agent` siempre. +2. La consulta del agente puede tardar unos segundos. Apenas llames la + herramienta avisale al usuario con una frase corta y natural ("dame un + segundo, lo busco", "ya te digo") y esperá la respuesta. +3. Cuando llegue la respuesta, transmitila fiel y completa en voz natural. No + agregues información que no esté en la respuesta. Si la respuesta pide una + confirmación, pedísela al usuario y mandá su decisión de vuelta con + `ask_agent` (ej.: "sí, confirmo"). +4. Solo charla trivial (saludos, "¿me escuchás?") se responde directo, breve. +5. Hablás español rioplatense, tono cercano y conciso. Sin emojis, sin listas + leídas como listas: convertí todo a frases habladas naturales.""" + if PERSONA: + base += "\n\nPersona adicional:\n" + PERSONA.strip() + return base + + +def temporal_context(now: datetime = None) -> str: + """Same role as the WhatsApp bridge's buildTemporalContext(): pin relative + dates per turn so the agent doesn't drift across a long voice session.""" + tz = ZoneInfo(TIME_ZONE) + now = now or datetime.now(tz) + today = now.strftime("%Y-%m-%d") + tomorrow = (now + timedelta(days=1)).strftime("%Y-%m-%d") + yesterday = (now - timedelta(days=1)).strftime("%Y-%m-%d") + return ( + "[Contexto temporal del turno]\n" + f"Hoy ({TIME_ZONE}) es {today}. Mañana es {tomorrow}. Ayer fue {yesterday}.\n" + "Usá estas fechas para interpretar fechas relativas. No copies estas líneas en respuestas.\n" + "\n[Mensaje del usuario (por voz)]\n" + ) + + +# ── ask_agent: forward a turn to the openclaWP agenttic endpoint ────────────── + + +async def ask_agent(consulta: str, session: dict) -> str: + """POST the user's request to the agent and return its text reply. + + `session` carries the sticky openclawp sessionId for this voice session so + the agent keeps conversational memory across turns. + """ + auth = read_auth() + req_id = f"voz-{int(time.time() * 1000)}-{uuid.uuid4().hex[:6]}" + params = { + "id": f"task-{req_id}", + "message": { + "role": "user", + "parts": [{"type": "text", "text": temporal_context() + consulta}], + "messageId": f"msg-{req_id}", + "kind": "message", + }, + } + if session.get("sessionId"): + params["sessionId"] = session["sessionId"] + + body = {"jsonrpc": "2.0", "id": req_id, "method": "message/send", "params": params} + url = f"{WP_BASE}/wp-json/openclawp/v1/agenttic/{AGENT_SLUG}" + + async with httpx.AsyncClient(timeout=120.0) as client: + res = await client.post(url, json=body, auth=(auth["user"], auth["app_password"])) + if res.status_code != 200: + logger.error("ask_agent HTTP %s: %s", res.status_code, res.text[:200]) + return "El agente no está disponible en este momento. Probá de nuevo en un rato." + data = res.json() + if data.get("error"): + logger.error("ask_agent rpc error: %s", json.dumps(data["error"])[:200]) + return "El agente devolvió un error. Probá reformular el pedido." + + result = data.get("result") or {} + if result.get("sessionId"): + session["sessionId"] = result["sessionId"] + parts = ((result.get("status") or {}).get("message") or {}).get("parts") or [] + text = "\n".join( + p["text"] for p in parts if p.get("type") == "text" and isinstance(p.get("text"), str) + ).strip() + return text or "El agente no devolvió respuesta." + + +# ── Gemini Live session setup ───────────────────────────────────────────────── + + +def build_setup_message() -> dict: + return { + "setup": { + "model": f"models/{GEMINI_MODEL}", + "generation_config": { + "response_modalities": ["AUDIO"], + "speech_config": { + "voice_config": {"prebuilt_voice_config": {"voice_name": GEMINI_VOICE}} + }, + }, + "input_audio_transcription": {}, + "output_audio_transcription": {}, + "system_instruction": {"parts": [{"text": build_system_instruction()}]}, + "tools": [ + { + "function_declarations": [ + { + "name": "ask_agent", + "description": ( + f"Envía el pedido del usuario al agente {AGENT_LABEL} y devuelve su " + "respuesta. Usala para TODA consulta de datos, registros o acciones." + ), + "parameters": { + "type": "OBJECT", + "properties": { + "consulta": { + "type": "STRING", + "description": "El pedido del usuario, completo y fiel.", + } + }, + "required": ["consulta"], + }, + } + ] + } + ], + } + } + + +# ── WebSocket bridge ────────────────────────────────────────────────────────── + +app = FastAPI() + + +@app.get("/healthz") +async def healthz(): + missing = validate_config() + return {"ok": not missing, "missing": missing, "agent": AGENT_SLUG, "model": GEMINI_MODEL} + + +@app.websocket("/ws/voice") +async def voice_endpoint(websocket: WebSocket): + await websocket.accept() + missing = validate_config() + if missing: + await websocket.send_json({"text": f"Gateway sin configurar: faltan {', '.join(missing)}"}) + await websocket.close(code=1008) + return + + session = {"sessionId": None} + audio_in = 0 # bytes of PCM uploaded (16 kHz mono s16 → 32000 B/s) + audio_out = 0 # bytes of PCM played back (24 kHz mono s16 → 48000 B/s) + user_text = "" + model_text = "" + + url = f"{GEMINI_WS_URL}?key={GEMINI_API_KEY}" + logger.info("Voice session start: agent=%s model=%s", AGENT_SLUG, GEMINI_MODEL) + + try: + async with websockets.connect(url, max_size=16 * 1024 * 1024) as gemini_ws: + send_lock = asyncio.Lock() + + async def send_to_gemini(payload: dict): + async with send_lock: + await gemini_ws.send(json.dumps(payload)) + + await send_to_gemini(build_setup_message()) + + async def run_tool(call: dict): + name = call.get("name", "") + args = call.get("args") or {} + call_id = call.get("id") + if name == "ask_agent": + consulta = str(args.get("consulta", "")).strip() + logger.info("ask_agent: %s", consulta[:120]) + await websocket.send_json({"type": "agent_thought", "text": consulta}) + try: + output = await ask_agent(consulta, session) + except Exception as e: # noqa: BLE001 — must always answer Gemini + logger.exception("ask_agent failed") + output = f"Error consultando al agente: {e}" + else: + output = f"Herramienta desconocida: {name}" + func_resp = {"name": name, "response": {"output": output}} + if call_id: + func_resp["id"] = call_id + await send_to_gemini({"toolResponse": {"functionResponses": [func_resp]}}) + + async def forward_to_gemini(): + nonlocal audio_in + async for message in websocket.iter_text(): + data = json.loads(message) + if "audio" in data: + audio_in += len(data["audio"]) * 3 // 4 # b64 → bytes, close enough + await send_to_gemini( + { + "realtime_input": { + "audio": { + "mime_type": "audio/pcm;rate=16000", + "data": data["audio"], + } + } + } + ) + elif "text" in data: + await send_to_gemini( + { + "client_content": { + "turns": [ + {"role": "user", "parts": [{"text": data["text"]}]} + ], + "turn_complete": True, + } + } + ) + + async def forward_from_gemini(): + nonlocal audio_out, user_text, model_text + async for raw in gemini_ws: + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + response = json.loads(raw) + + if "setupComplete" in response or "setup_complete" in response: + await websocket.send_json({"type": "ready"}) + continue + + content = response.get("serverContent") or response.get("server_content") + if content: + if content.get("interrupted"): + await websocket.send_json({"type": "interrupted"}) + model_text = "" + continue + + in_tr = content.get("inputTranscription") or content.get( + "input_transcription" + ) + if in_tr and in_tr.get("text"): + user_text += in_tr["text"] + await websocket.send_json( + {"type": "user_transcript", "text": user_text, "final": False} + ) + + out_tr = content.get("outputTranscription") or content.get( + "output_transcription" + ) + if out_tr and out_tr.get("text"): + model_text += out_tr["text"] + await websocket.send_json( + {"type": "agent_transcript", "text": model_text, "final": False} + ) + + turn = content.get("modelTurn") or content.get("model_turn") + if turn: + if user_text: + await websocket.send_json( + {"type": "user_transcript", "text": user_text, "final": True} + ) + user_text = "" + for part in turn.get("parts", []): + inline = part.get("inlineData") or part.get("inline_data") + if inline and inline.get("data"): + audio_out += len(inline["data"]) * 3 // 4 + await websocket.send_json({"audio": inline["data"]}) + + if content.get("turnComplete") or content.get("turn_complete"): + if model_text: + await websocket.send_json( + {"type": "agent_transcript", "text": model_text, "final": True} + ) + model_text = "" + + tool_call = response.get("toolCall") or response.get("tool_call") + if tool_call: + calls = tool_call.get("functionCalls") or tool_call.get("function_calls") or [] + for call in calls: + asyncio.create_task(run_tool(call)) + + done, pending = await asyncio.wait( + [ + asyncio.create_task(forward_to_gemini()), + asyncio.create_task(forward_from_gemini()), + ], + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + for task in done: + if not task.cancelled() and task.exception(): + raise task.exception() + except WebSocketDisconnect: + pass + except Exception: + logger.exception("Voice session error") + try: + await websocket.send_json({"text": "Se cortó la sesión de voz. Recargá para reintentar."}) + except Exception: # noqa: BLE001 — socket may already be gone + pass + finally: + secs_in = audio_in / 32000.0 + secs_out = audio_out / 48000.0 + logger.info( + "Voice session end: %.1fs in, %.1fs out, openclawp session=%s", + secs_in, + secs_out, + session.get("sessionId"), + ) + + +# Static frontend — served last so /ws/voice and /healthz win. +STATIC_DIR = Path(__file__).parent / "static" + + +@app.get("/") +async def index(): + return FileResponse(STATIC_DIR / "index.html") + + +app.mount("/", StaticFiles(directory=str(STATIC_DIR)), name="static") + + +if __name__ == "__main__": + import uvicorn + + missing = validate_config() + if missing: + logger.warning("Config incompleta (el WS la rechaza hasta resolverla): %s", missing) + uvicorn.run(app, host=HOST, port=PORT) diff --git a/voice-gateway/requirements.txt b/voice-gateway/requirements.txt new file mode 100644 index 0000000..4a0811c --- /dev/null +++ b/voice-gateway/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.110 +uvicorn>=0.29 +websockets>=12.0 +httpx>=0.27 +python-dotenv>=1.0 diff --git a/voice-gateway/static/index.html b/voice-gateway/static/index.html new file mode 100644 index 0000000..b01e392 --- /dev/null +++ b/voice-gateway/static/index.html @@ -0,0 +1,191 @@ + + + + + +Voz — openclaWP + + + +
+

Hablar con el agente

+ Desconectado +
+
+
+ + +
+ + + From 8d345fe35d84f0a6bb5eefff84627fe00416eb4f Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Fri, 12 Jun 2026 19:39:45 -0300 Subject: [PATCH 2/2] Voice session credentials: WP mints ephemeral Live tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /openclawp/v1/voice/session exchanges the Google key already stored in the AI Client credential store (read via AiClient::defaultRegistry() ->getProviderRequestAuthentication) for a single-use Gemini Live ephemeral token (v1alpha auth_tokens, model-constrained, 30 min expiry). The long-lived key never leaves WordPress; returning it raw sits behind an off-by-default filter. The gateway now asks WP for its credential per voice session (?access_token= on the v1alpha Bidi WS) and keeps a local GEMINI_API_KEY only as fallback for older sites. Shaped deliberately as a core proposal proof of concept: php-ai-client providers could expose ephemeral client credentials for realtime sessions natively — see docs/voice-gateway-core-proposal.md. Co-Authored-By: Claude Fable 5 --- docs/voice-gateway-core-proposal.md | 108 ++++++++ includes/class-openclawp-bootstrap.php | 1 + includes/class-openclawp-voice-session.php | 287 +++++++++++++++++++++ voice-gateway/README.md | 13 +- voice-gateway/gateway.py | 58 ++++- 5 files changed, 462 insertions(+), 5 deletions(-) create mode 100644 docs/voice-gateway-core-proposal.md create mode 100644 includes/class-openclawp-voice-session.php diff --git a/docs/voice-gateway-core-proposal.md b/docs/voice-gateway-core-proposal.md new file mode 100644 index 0000000..759e351 --- /dev/null +++ b/docs/voice-gateway-core-proposal.md @@ -0,0 +1,108 @@ +# Core proposal: ephemeral client credentials for realtime AI sessions + +**Status:** userland proof of concept shipped in openclaWP +(`includes/class-openclawp-voice-session.php` + `voice-gateway/`). +**Audience:** php-ai-client / wp-ai-client (WordPress AI Team). + +## Problem + +Realtime AI sessions — voice (Gemini Live, OpenAI Realtime), screen sharing, +live transcription — run over long-lived WebSocket/WebRTC connections that PHP +cannot hold. The session is therefore established by a *client*: a browser, a +native app, or a sidecar daemon next to WordPress. + +That client needs a provider credential. Today the only options are: + +1. **Configure the provider API key in the client too** — two sources of + truth, long-lived secret outside WordPress, no way to revoke per client. +2. **Hand the stored key out over REST** — the key wp-ai-client guards in its + credential store leaks to every client that may start a session. + +Both contradict the premise of the AI Client credential store: WordPress owns +the key. + +## What providers already offer + +Providers solve this with **short-lived, scoped credentials** minted +server-side from the long-lived key: + +- **Google Gemini Live**: `v1alpha/auth_tokens` — single-use tokens with + `expireTime`, `newSessionExpireTime` and `liveConnectConstraints` (pin the + model and Live config the token can be used with). Passed as + `?access_token=` on the Bidi WebSocket. +- **OpenAI Realtime**: `POST /v1/realtime/sessions` returns an ephemeral + `client_secret` for the browser to open the WebRTC/WS session. + +The shape is identical: *exchange the server-held key for a constrained, +expiring client credential.* + +## Proposal + +php-ai-client adds a provider capability for minting ephemeral client +credentials, and wp-ai-client exposes it to consumers: + +```php +interface ProviderWithEphemeralCredentialsInterface +{ + public static function ephemeralCredentials(): EphemeralCredentialHandlerInterface; +} + +interface EphemeralCredentialHandlerInterface +{ + /** + * Exchanges the provider's stored request authentication for a + * short-lived client credential, optionally constrained. + */ + public function createCredential(EphemeralCredentialConstraints $constraints): EphemeralCredential; +} + +final class EphemeralCredential +{ + public function getType(): string; // e.g. 'ephemeral_token' + public function getValue(): string; // never the long-lived key + public function getExpiresAt(): DateTimeImmutable; + public function getConnectionInfo(): array; // e.g. ws_url, auth param style +} +``` + +Consumer code (a voice feature, a realtime block, a sidecar handshake +endpoint) becomes provider-neutral: + +```php +$credential = AiClient::defaultRegistry() + ->createEphemeralCredential( 'google', $constraints ); +``` + +WordPress keeps sole custody of the long-lived key; clients receive only +expiring, scoped credentials; revocation and auditing happen where the key +lives. + +## The proof of concept in this repo + +`POST /openclawp/v1/voice/session` implements the consumer side today: + +1. Reads the Google key the canonical way — + `AiClient::defaultRegistry()->getProviderRequestAuthentication( 'google' )` + (the key never enters the REST response). +2. Mints a single-use Live token (`v1alpha/auth_tokens`, constrained to the + session's model, 30-minute expiry). +3. Returns `{credential, ws_url, model}` to the voice client. + +Returning the raw key is behind an off-by-default filter +(`openclawp_voice_session_allow_api_key`) precisely because the core-shaped +contract is "the stored key never leaves the server". + +If the proposal lands, step 2 collapses into the registry call above and the +endpoint loses all provider-specific code — which is the point: today the +Google `auth_tokens` HTTP call is written by hand in a plugin, where it can +drift; in core it would sit next to the provider that owns the protocol. + +## Why core (and not just plugins) + +- The credential store is already core's (`wp-ai-client`, WP 7.0). Ephemeral + exchange is a property *of the stored credential*, not of any product. +- Every realtime consumer (voice UIs, live blocks, agents talking on the + phone) needs the same exchange; without core support each plugin + re-implements per-provider token minting against undocumented-in-PHP APIs. +- It is small: one interface, one DTO, per-provider implementations of a + single HTTP call. No session management, no transport, no UI. diff --git a/includes/class-openclawp-bootstrap.php b/includes/class-openclawp-bootstrap.php index 21444b6..7cea735 100644 --- a/includes/class-openclawp-bootstrap.php +++ b/includes/class-openclawp-bootstrap.php @@ -71,6 +71,7 @@ public static function init(): void { OpenclaWP_Agency_Rest::register(); OpenclaWP_Decisions_Rest::register(); OpenclaWP_Agenttic_Bridge::register(); + OpenclaWP_Voice_Session::register(); OpenclaWP_Agent_Card::register(); OpenclaWP_A2a_Client_Bridge::register(); OpenclaWP_Canonical_Chat_Handler::register(); diff --git a/includes/class-openclawp-voice-session.php b/includes/class-openclawp-voice-session.php new file mode 100644 index 0000000..41870ba --- /dev/null +++ b/includes/class-openclawp-voice-session.php @@ -0,0 +1,287 @@ + WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'handle' ), + 'permission_callback' => array( __CLASS__, 'check_permission' ), + 'args' => array( + 'agent' => array( + 'type' => 'string', + 'required' => false, + 'sanitize_callback' => 'sanitize_title', + ), + 'model' => array( + 'type' => 'string', + 'required' => false, + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + ) + ); + } + + public static function check_permission( WP_REST_Request $request ) { + $default = current_user_can( 'manage_options' ); + + /** + * Filters whether the current user may mint a voice session + * credential. Mirrors `openclawp_agenttic_bridge_permission`: a voice + * client that may chat with the agent should normally also be allowed + * here. + * + * @since 0.6.0 + * + * @param bool $allowed Default: manage_options. + * @param WP_REST_Request $request Current request. + */ + $allowed = (bool) apply_filters( 'openclawp_voice_session_permission', $default, $request ); + + if ( ! $allowed ) { + return new WP_Error( + 'openclawp_forbidden', + __( 'You do not have permission to start a voice session.', 'openclawp' ), + array( 'status' => 403 ) + ); + } + + return true; + } + + public static function handle( WP_REST_Request $request ) { + $model = (string) $request->get_param( 'model' ); + if ( '' === $model ) { + $model = self::DEFAULT_MODEL; + } + + /** + * Filters the Gemini Live model a voice session is constrained to. + * + * @since 0.6.0 + * + * @param string $model Model slug (no `models/` prefix). + * @param WP_REST_Request $request Current request. + */ + $model = (string) apply_filters( 'openclawp_voice_session_model', $model, $request ); + + $api_key = self::get_provider_api_key( 'google' ); + if ( '' === $api_key ) { + return new WP_Error( + 'openclawp_voice_no_credentials', + __( 'No Google AI credentials are configured in the AI Client.', 'openclawp' ), + array( 'status' => 501 ) + ); + } + + $token = self::mint_google_token( $api_key, $model ); + if ( ! is_wp_error( $token ) ) { + return rest_ensure_response( + array_merge( + array( + 'provider' => 'google', + 'model' => $model, + 'credential' => $token, + 'ws_url' => 'wss://' . wp_parse_url( self::GOOGLE_API_BASE, PHP_URL_HOST ) + . '/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent', + ), + self::agent_info( (string) $request->get_param( 'agent' ) ) + ) + ); + } + + /** + * Filters whether the endpoint may fall back to returning the + * long-lived API key when ephemeral-token minting fails. Off by + * default on purpose: the core-shaped contract is that the stored + * key never leaves the server. + * + * @since 0.6.0 + * + * @param bool $allowed Default false. + * @param WP_REST_Request $request Current request. + */ + if ( apply_filters( 'openclawp_voice_session_allow_api_key', false, $request ) ) { + return rest_ensure_response( + array_merge( + array( + 'provider' => 'google', + 'model' => $model, + 'credential' => array( + 'type' => 'api_key', + 'value' => $api_key, + ), + 'ws_url' => 'wss://' . wp_parse_url( self::GOOGLE_API_BASE, PHP_URL_HOST ) + . '/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent', + ), + self::agent_info( (string) $request->get_param( 'agent' ) ) + ) + ); + } + + return $token; + } + + /** + * Reads a provider API key from the AI Client credential store — the + * core-owned path: WP stores it, `AiClient::defaultRegistry()` exposes it + * to PHP, and it is never serialized into a REST response. + */ + private static function get_provider_api_key( string $provider_id ): string { + if ( ! class_exists( '\\WordPress\\AiClient\\AiClient' ) ) { + return ''; + } + + try { + $registry = \WordPress\AiClient\AiClient::defaultRegistry(); + $auth = $registry->getProviderRequestAuthentication( $provider_id ); + if ( is_object( $auth ) && method_exists( $auth, 'getApiKey' ) ) { + return (string) $auth->getApiKey(); + } + } catch ( Throwable $e ) { + return ''; + } + + return ''; + } + + /** + * Mints a single-use ephemeral token for the Live API + * (`v1alpha/auth_tokens`). The token can open exactly one Live session, + * constrained to $model, and expires in 30 minutes. + * + * @return array{type: string, value: string, expires_at: string}|WP_Error + */ + private static function mint_google_token( string $api_key, string $model ) { + $expire_at = gmdate( 'Y-m-d\TH:i:s\Z', time() + 30 * MINUTE_IN_SECONDS ); + $new_session_by = gmdate( 'Y-m-d\TH:i:s\Z', time() + 2 * MINUTE_IN_SECONDS ); + + $body = array( + 'uses' => 1, + 'expireTime' => $expire_at, + 'newSessionExpireTime' => $new_session_by, + 'liveConnectConstraints' => array( + 'model' => 'models/' . $model, + ), + ); + + /** + * Filters the auth_tokens:create request body, e.g. to pin the full + * Live config into the token constraints. + * + * @since 0.6.0 + * + * @param array $body Request body. + * @param string $model Model slug. + */ + $body = (array) apply_filters( 'openclawp_voice_token_request', $body, $model ); + + $response = wp_remote_post( + self::GOOGLE_API_BASE . '/v1alpha/auth_tokens', + array( + 'timeout' => 15, + 'headers' => array( + 'Content-Type' => 'application/json', + 'x-goog-api-key' => $api_key, + ), + 'body' => wp_json_encode( $body ), + ) + ); + + if ( is_wp_error( $response ) ) { + return new WP_Error( + 'openclawp_voice_mint_failed', + $response->get_error_message(), + array( 'status' => 502 ) + ); + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( 200 !== $code || empty( $data['name'] ) ) { + return new WP_Error( + 'openclawp_voice_mint_failed', + sprintf( + /* translators: %d: HTTP status code from the Google API. */ + __( 'Ephemeral token minting failed (HTTP %d).', 'openclawp' ), + $code + ), + array( + 'status' => 502, + 'upstream' => is_array( $data ) ? ( $data['error']['message'] ?? null ) : null, + ) + ); + } + + return array( + 'type' => 'ephemeral_token', + 'value' => (string) $data['name'], + 'expires_at' => $expire_at, + ); + } + + /** + * Optional agent display info so the voice shell can introduce itself + * without extra round-trips. Never includes the agent's instructions. + * + * @return array{agent?: array{slug: string, label: string}} + */ + private static function agent_info( string $agent_slug ): array { + if ( '' === $agent_slug || ! function_exists( 'wp_get_agent' ) ) { + return array(); + } + + $agent = wp_get_agent( $agent_slug ); + if ( ! $agent ) { + return array(); + } + + $label = is_object( $agent ) && method_exists( $agent, 'get_label' ) + ? (string) $agent->get_label() + : $agent_slug; + + return array( + 'agent' => array( + 'slug' => $agent_slug, + 'label' => $label, + ), + ); + } +} diff --git a/voice-gateway/README.md b/voice-gateway/README.md index fcad274..798e742 100644 --- a/voice-gateway/README.md +++ b/voice-gateway/README.md @@ -88,9 +88,20 @@ launchctl load ~/Library/LaunchAgents/com.openclawp.voice-gateway.plist ## Configuration +### Credentials: WordPress mints them (preferred) + +On session start the gateway calls `POST /openclawp/v1/voice/session`, which +exchanges the Google key already stored in WordPress' AI Client credential +store for a **single-use ephemeral Live token** (the long-lived key never +leaves WP). See `docs/voice-gateway-core-proposal.md` — this flow is shaped as +a proof of concept of what wp-ai-client could support natively. + +`GEMINI_API_KEY` in the local `.env` is only a fallback for sites whose +openclaWP predates `/voice/session` or has no Google credentials stored. + | Variable | Default | Meaning | |---|---|---| -| `GEMINI_API_KEY` | — | AI Studio key (`GOOGLE_API_KEY` fallback) | +| `GEMINI_API_KEY` | — | Optional fallback AI Studio key (`GOOGLE_API_KEY` works too) | | `OPENCLAWP_VOICE_WP_BASE` | — | Site URL, no trailing slash | | `OPENCLAWP_VOICE_AGENT` | — | Agent slug (`wp_register_agent`) | | `OPENCLAWP_VOICE_AGENT_LABEL` | slug | Name the voice shell uses | diff --git a/voice-gateway/gateway.py b/voice-gateway/gateway.py index 90316cf..ccad101 100644 --- a/voice-gateway/gateway.py +++ b/voice-gateway/gateway.py @@ -77,14 +77,15 @@ def validate_config() -> list: missing = [] - if not GEMINI_API_KEY: - missing.append("GEMINI_API_KEY") if not WP_BASE: missing.append("OPENCLAWP_VOICE_WP_BASE") if not AGENT_SLUG: missing.append("OPENCLAWP_VOICE_AGENT") if not AUTH_FILE.exists(): missing.append(f"auth file {AUTH_FILE} (JSON {{user, app_password}})") + # GEMINI_API_KEY is optional: the preferred path is an ephemeral token + # minted by WP (POST /openclawp/v1/voice/session) from the key the AI + # Client credential store already owns. return missing @@ -181,6 +182,47 @@ async def ask_agent(consulta: str, session: dict) -> str: return text or "El agente no devolvió respuesta." +# ── Live credential: WP-minted ephemeral token, env API key as fallback ────── + + +async def get_live_credential() -> tuple: + """Returns (ws_url_with_credential, source). + + Preferred: ask WordPress to mint a single-use ephemeral Live token from + the provider key stored in the AI Client (the key never reaches us). + Fallback: GEMINI_API_KEY from the local environment, for sites whose + openclaWP predates /voice/session or has no Google credentials stored. + """ + auth = read_auth() + url = f"{WP_BASE}/wp-json/openclawp/v1/voice/session" + try: + async with httpx.AsyncClient(timeout=20.0) as client: + res = await client.post( + url, + json={"agent": AGENT_SLUG, "model": GEMINI_MODEL}, + auth=(auth["user"], auth["app_password"]), + ) + if res.status_code == 200: + data = res.json() + cred = data.get("credential") or {} + ws_url = data.get("ws_url") or GEMINI_WS_URL + if cred.get("value"): + logger.info("Using WP-minted credential (%s).", cred.get("type")) + # Ephemeral tokens authenticate via ?access_token= (v1alpha + # only); plain API keys via ?key=. + param = "access_token" if cred.get("type") == "ephemeral_token" else "key" + return f"{ws_url}?{param}={cred['value']}", cred.get("type", "unknown") + else: + logger.warning("voice/session HTTP %s: %s", res.status_code, res.text[:200]) + except Exception as e: # noqa: BLE001 — fall back to env key + logger.warning("voice/session unreachable: %s", e) + + if GEMINI_API_KEY: + logger.info("Falling back to local GEMINI_API_KEY.") + return f"{GEMINI_WS_URL}?key={GEMINI_API_KEY}", "env_api_key" + return "", "" + + # ── Gemini Live session setup ───────────────────────────────────────────────── @@ -250,8 +292,16 @@ async def voice_endpoint(websocket: WebSocket): user_text = "" model_text = "" - url = f"{GEMINI_WS_URL}?key={GEMINI_API_KEY}" - logger.info("Voice session start: agent=%s model=%s", AGENT_SLUG, GEMINI_MODEL) + url, cred_source = await get_live_credential() + if not url: + await websocket.send_json( + {"text": "Sin credenciales para Gemini Live: configurá la key de Google en el AI Client de WP o GEMINI_API_KEY local."} + ) + await websocket.close(code=1008) + return + logger.info( + "Voice session start: agent=%s model=%s cred=%s", AGENT_SLUG, GEMINI_MODEL, cred_source + ) try: async with websockets.connect(url, max_size=16 * 1024 * 1024) as gemini_ws: