Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions src/eva/backend/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
"""Provider-agnostic ``Backend`` abstraction (design-only, Step 1 of the refactor).
"""Provider-agnostic ``Backend`` abstraction (see ``docs/refactor-step1.md``).

This package defines the contracts described in ``docs/refactor-step1.md``:
pure API/session objects (``Backend``) that know nothing about role
(assistant vs. user), plus a factory to construct them. Nothing in this
package is wired into the existing ``eva.assistant`` / ``eva.user_simulator``
code yet -- these are new, additive, currently-unused types.
This package defines pure API/session objects (``Backend``) that know nothing
about role (assistant vs. user), plus a ``BackendFactory`` to construct them.
The worker builds a backend per conversation and drives it through an
``AssistantRole`` / ``UserRole`` for every provider the factory supports.
"""

from eva.backend.base import Backend, BackendEvent, BackendEventType, ToolCallRequest, ToolCallResult
from eva.backend.base import (
Backend,
BackendEvent,
BackendEventType,
BackendSession,
ToolCallRequest,
ToolCallResult,
)
from eva.backend.capabilities import BackendCapabilities
from eva.backend.factory import BackendFactory

Expand All @@ -17,6 +23,7 @@
"BackendEvent",
"BackendEventType",
"BackendFactory",
"BackendSession",
"ToolCallRequest",
"ToolCallResult",
]
230 changes: 158 additions & 72 deletions src/eva/backend/base.py

Large diffs are not rendered by default.

74 changes: 39 additions & 35 deletions src/eva/backend/factory.py
Original file line number Diff line number Diff line change
@@ -1,55 +1,59 @@
"""Factory interface for constructing ``Backend`` instances by name.

DESIGN ONLY (Step 1 of the refactor). Mirrors the shape of today's
``eva.user_simulator.factory.create_user_simulator`` (lazy per-provider
imports keyed off config type) but is provider-and-role-agnostic: the same
factory is meant to be usable to build a backend for either an
``AssistantRole`` or a ``UserRole``, since a ``Backend`` has no role
knowledge (that's the whole point of the split -- see docs/refactor-step1.md,
"lets any backend act as either role").
"""Factory that constructs ``Backend`` instances by provider name.

Mirrors the shape of ``eva.user_simulator.factory.create_user_simulator`` (lazy
per-provider imports keyed off a provider name) but is
provider-and-role-agnostic: the same factory builds a backend for either an
``AssistantRole`` or a ``UserRole``, since a ``Backend`` has no role knowledge
(that's the whole point of the split -- see docs/refactor-step1.md, "lets any
backend act as either role").

A single concrete class -- there is no abstract base, since there is only ever
one factory. ``create`` returns ``None`` for a provider that has not been
migrated onto the ``Backend`` contract; the worker uses that as the signal to
fall back to the legacy server/simulator for that provider.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from typing import Any

from eva.backend.base import Backend


class BackendFactory(ABC):
class BackendFactory:
"""Constructs a ``Backend`` for a named provider from a config blob.

A concrete implementation is expected to hold (or look up) a registry
mapping provider name -> ``Backend`` subclass, analogous to today's
``create_user_simulator`` / assistant-server construction in
``orchestrator/runner.py``, and to import each provider module lazily so
that unused providers' SDKs need not be installed/imported.
Providers are added as their backends are migrated onto the ``Backend``
contract: add a lazy-import branch in ``create``. Each provider's SDK is
imported only when that provider is selected, so unused providers need not
be importable.
"""

@abstractmethod
def create(self, name: str, config: dict[str, Any]) -> Backend:
"""Construct and return a not-yet-opened ``Backend``.
def create(self, name: str, config: dict[str, Any]) -> Backend | None:
"""Construct a not-yet-opened ``Backend``, or ``None`` if unsupported.

Args:
name: Provider identifier (e.g. ``"openai_realtime"``,
``"gemini_live"``, ``"elevenlabs"``, ``"cascade"``). The set
of valid names is defined by the concrete factory's registry,
not by this interface.
``"gemini_live"``, ``"elevenlabs"``, ``"cascade"``).
config: Provider-specific configuration understood by that
backend's ``open()`` (see ``Backend.open``). This factory
does not validate the shape of ``config`` beyond dispatching
on ``name`` -- each ``Backend`` subclass is responsible for
validating its own config.
backend. This factory does not validate the shape of
``config`` beyond dispatching on ``name`` -- each ``Backend``
subclass validates its own config and assembles its own
provider session (the caller hand-builds no provider JSON).

Returns:
A constructed ``Backend`` instance. The returned backend has not
had ``open()`` called on it yet -- construction and session
establishment are separate steps so a ``Role`` can construct its
backend early (e.g. at record setup) and open the session later
(e.g. once the other party is ready).

Raises:
ValueError: if ``name`` does not match a known provider.
A constructed ``Backend`` for a migrated provider, not yet
``open()``ed (construction and session establishment are separate
steps, so a ``Role`` can build its backend early and open the
session later). ``None`` if ``name`` is not a migrated provider --
the worker then falls back to the legacy server/simulator.
"""
...
if name == "openai_realtime":
from eva.backend.openai_realtime import OpenAIRealtimeBackend

return OpenAIRealtimeBackend(config=config)
if name == "grok_voice":
from eva.backend.grok_voice import GrokVoiceBackend

return GrokVoiceBackend(config=config)
return None
97 changes: 97 additions & 0 deletions src/eva/backend/grok_voice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Grok Voice ``Backend``: xAI's voice realtime API (OpenAI Realtime-compatible).

xAI's voice realtime API is event-compatible with OpenAI's Realtime API
(https://docs.x.ai/developers/model-capabilities/audio/voice-agent), so this
backend subclasses ``OpenAIRealtimeBackend`` and overrides only what differs --
mirroring how ``eva.assistant.grok_voice_server.GrokVoiceAssistantServer``
subclasses the OpenAI Realtime server:

- endpoint: point the client at ``https://api.x.ai/v1`` (default ``base_url``);
- default voice: xAI's built-in voices (``eve``/``ara``/``rex``/``sal``/``leo``);
- api key: falls back to ``XAI_API_KEY`` (not ``OPENAI_API_KEY``, which would be
the wrong key for x.ai) when not supplied in config;
- input transcription: xAI fires
``conversation.item.input_audio_transcription.completed`` multiple times per
turn with progressively longer text, so instead of surfacing each as a final
``TRANSCRIPT`` (which the OpenAI backend does), buffer it and emit one final
``TRANSCRIPT`` when the turn settles (next speech start / ``response.done``).

Everything else -- session assembly, audio, tool round-trip, interruption,
usage, the rest of the event normalization -- is inherited unchanged.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any, ClassVar

from eva.backend.base import BackendEvent, BackendEventType
from eva.backend.openai_realtime import OpenAIRealtimeBackend, OpenAIRealtimeSession
from eva.utils.logging import get_logger

logger = get_logger(__name__)

XAI_REALTIME_BASE_URL = "https://api.x.ai/v1"
DEFAULT_VOICE = "eve"


@dataclass
class GrokVoiceSession(OpenAIRealtimeSession):
"""OpenAI Realtime session state plus xAI's buffered input transcript.

``pending_input_transcript`` accumulates the latest (progressively longer)
``input_audio_transcription.completed`` text for the current turn; it is
flushed as a single final ``TRANSCRIPT`` when the turn settles.
"""

pending_input_transcript: str = ""


class GrokVoiceBackend(OpenAIRealtimeBackend):
"""xAI Grok voice realtime behind the role-agnostic ``Backend`` contract."""

_SESSION_CLS: ClassVar[type[OpenAIRealtimeSession]] = GrokVoiceSession
_API_KEY_ENV: ClassVar[str] = "XAI_API_KEY"

def __init__(self, *, config: dict[str, Any]) -> None:
# xAI defaults; any explicit config value wins. api_key falls back to
# XAI_API_KEY in the parent (via _API_KEY_ENV).
merged = {"base_url": XAI_REALTIME_BASE_URL, "voice": DEFAULT_VOICE, **config}
super().__init__(config=merged)

@staticmethod
def _map_event(session: OpenAIRealtimeSession, event: Any) -> list[BackendEvent]:
"""Normalize one xAI event, buffering incremental input transcriptions.

Defers to ``OpenAIRealtimeBackend._map_event`` for everything except the
progressive ``input_audio_transcription.completed`` stream, which is
buffered and flushed as one final input ``TRANSCRIPT`` when the turn
settles (mirrors ``GrokVoiceAssistantServer``'s deferred transcript).
"""
event_type = getattr(event, "type", "")

if event_type == "conversation.item.input_audio_transcription.completed":
transcript = (getattr(event, "transcript", "") or "").strip()
if transcript and isinstance(session, GrokVoiceSession):
session.pending_input_transcript = transcript # buffer, don't emit yet
return []

# Flush the buffered transcript just before the turn boundary is handled.
if event_type in ("input_audio_buffer.speech_started", "response.done"):
return GrokVoiceBackend._flush_pending_input(session) + OpenAIRealtimeBackend._map_event(session, event)

return OpenAIRealtimeBackend._map_event(session, event)

@staticmethod
def _flush_pending_input(session: OpenAIRealtimeSession) -> list[BackendEvent]:
if not isinstance(session, GrokVoiceSession) or not session.pending_input_transcript:
return []
text = session.pending_input_transcript
session.pending_input_transcript = ""
return [
BackendEvent(
event_type=BackendEventType.TRANSCRIPT,
transcript=text,
metadata={"stream": "input", "final": True},
)
]
Loading