From de3a537d4573be9f857f0afad73356485266ed35 Mon Sep 17 00:00:00 2001 From: Kevin Dawkins Date: Sun, 21 Jun 2026 17:04:36 -0700 Subject: [PATCH] feat(eve): add Vercel realtime speech channel Signed-off-by: Kevin Dawkins --- .changeset/realtime-speech-channel.md | 5 + apps/docs/lib/integrations/data.ts | 37 + apps/docs/lib/integrations/logos.tsx | 20 + apps/frameworks/next/agent/channel-auth.ts | 32 + apps/frameworks/next/agent/channels/eve.ts | 33 +- .../next/agent/channels/realtime-speech.ts | 45 + apps/frameworks/next/app/_chat/App.tsx | 221 ++++- apps/frameworks/next/app/_chat/styles.css | 95 +++ apps/frameworks/next/package.json | 2 + docs/channels/meta.json | 1 + docs/channels/realtime-speech.mdx | 152 ++++ packages/eve-catalog/src/index.ts | 7 + packages/eve/package.json | 25 + packages/eve/scripts/build-rolldown.mjs | 7 +- packages/eve/src/client/index.ts | 2 + packages/eve/src/client/url.test.ts | 15 + packages/eve/src/client/url.ts | 13 +- packages/eve/src/client/voice.test.ts | 72 ++ packages/eve/src/client/voice.ts | 93 ++ packages/eve/src/public/channels/index.ts | 8 + .../channels/vercel/control-token.test.ts | 107 +++ .../public/channels/vercel/control-token.ts | 189 +++++ .../channels/vercel/control-url.test.ts | 85 ++ .../src/public/channels/vercel/control-url.ts | 96 +++ .../src/public/channels/vercel/speech.test.ts | 209 +++++ .../eve/src/public/channels/vercel/speech.ts | 323 +++++++ .../channels/vercel/voice-control-protocol.ts | 17 + .../vercel/voice-turn-coordinator.test.ts | 287 +++++++ .../channels/vercel/voice-turn-coordinator.ts | 393 +++++++++ packages/eve/src/react/voice.test.ts | 526 ++++++++++++ packages/eve/src/react/voice.ts | 792 ++++++++++++++++++ pnpm-lock.yaml | 61 +- pnpm-workspace.yaml | 8 + ...k-gateway-4.0.0-beta.110-speech-engine.tgz | Bin 0 -> 121437 bytes 34 files changed, 3917 insertions(+), 61 deletions(-) create mode 100644 .changeset/realtime-speech-channel.md create mode 100644 apps/frameworks/next/agent/channel-auth.ts create mode 100644 apps/frameworks/next/agent/channels/realtime-speech.ts create mode 100644 docs/channels/realtime-speech.mdx create mode 100644 packages/eve/src/client/voice.test.ts create mode 100644 packages/eve/src/client/voice.ts create mode 100644 packages/eve/src/public/channels/vercel/control-token.test.ts create mode 100644 packages/eve/src/public/channels/vercel/control-token.ts create mode 100644 packages/eve/src/public/channels/vercel/control-url.test.ts create mode 100644 packages/eve/src/public/channels/vercel/control-url.ts create mode 100644 packages/eve/src/public/channels/vercel/speech.test.ts create mode 100644 packages/eve/src/public/channels/vercel/speech.ts create mode 100644 packages/eve/src/public/channels/vercel/voice-control-protocol.ts create mode 100644 packages/eve/src/public/channels/vercel/voice-turn-coordinator.test.ts create mode 100644 packages/eve/src/public/channels/vercel/voice-turn-coordinator.ts create mode 100644 packages/eve/src/react/voice.test.ts create mode 100644 packages/eve/src/react/voice.ts create mode 100644 vendor/ai-sdk-gateway-4.0.0-beta.110-speech-engine.tgz diff --git a/.changeset/realtime-speech-channel.md b/.changeset/realtime-speech-channel.md new file mode 100644 index 000000000..d3be76d72 --- /dev/null +++ b/.changeset/realtime-speech-channel.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Add a Vercel realtime speech channel (exported at `eve/channels/vercel/speech`) plus a React voice hook. The channel mints AI Gateway realtime client secrets so the browser can hold the audio socket, while finalized transcripts run as ordinary durable turns through the existing `/eve/v1/session` routes and event stream — no Eve request blocks for a full model turn, and spoken replies are read back on `message.completed`. `eve/react/voice` provides the browser hook (`useEveVoice`); non-React clients use `setupVoice` plus `client.session()`. diff --git a/apps/docs/lib/integrations/data.ts b/apps/docs/lib/integrations/data.ts index ac8e86ac6..7c7cf0656 100644 --- a/apps/docs/lib/integrations/data.ts +++ b/apps/docs/lib/integrations/data.ts @@ -299,6 +299,43 @@ export default eveChannel(); Point your frontend at the session routes eve serves (\`/eve/v1/session\`) and stream responses with the eve web client.`, configure: `The eve channel is the lowest-friction way to talk to your agent, with no third-party provisioning required. Layer in auth and route protection as needed. See the [eve channel docs](/docs/channels/eve) and the [Frontend guide](/docs/guides/frontend/overview).`, }, + "realtime-speech": { + logo: "voice", + docsHref: "/docs/channels/realtime-speech", + keywords: ["voice", "audio", "realtime", "microphone", "ai gateway"], + install: `Install the framework and the AI SDK React realtime peer: + +\`\`\`bash +npm install eve@latest @ai-sdk/react +\`\`\``, + quickStart: `Create \`agent/channels/speech.ts\`: + +\`\`\`ts +// agent/channels/speech.ts +import { vercelSpeechChannel } from "eve/channels/vercel/speech"; +import { localDev, vercelOidc } from "eve/channels/auth"; + +export default vercelSpeechChannel({ + auth: [localDev(), vercelOidc()], +}); +\`\`\` + +Then render a microphone wherever it fits your UI: + +\`\`\`tsx +"use client"; + +import { useEveVoice } from "eve/react/voice"; + +export function ComposerActions() { + const voice = useEveVoice(); + const active = voice.status === "connected" || voice.status === "connecting"; + + return ; +} +\`\`\``, + configure: `Set \`AI_GATEWAY_API_KEY\` so the setup route can mint short-lived AI Gateway realtime client secrets. The browser keeps the realtime audio socket open, while each finalized utterance runs as an ordinary durable turn through the existing \`/eve/v1/session\` routes and event stream. The voice session id is a client-visible correlation id only; principal binding comes from normal session-route auth.`, + }, }; /** diff --git a/apps/docs/lib/integrations/logos.tsx b/apps/docs/lib/integrations/logos.tsx index 69e15f7ee..787ae97c8 100644 --- a/apps/docs/lib/integrations/logos.tsx +++ b/apps/docs/lib/integrations/logos.tsx @@ -21,6 +21,25 @@ export const webLogo = (props: LogoProps) => ( ); +export const voiceLogo = (props: LogoProps) => ( + + + + +); + export const githubLogo = (props: LogoProps) => ( ( export const logos = { eve: eveLogo, web: webLogo, + voice: voiceLogo, github: githubLogo, slack: slackLogo, discord: discordLogo, diff --git a/apps/frameworks/next/agent/channel-auth.ts b/apps/frameworks/next/agent/channel-auth.ts new file mode 100644 index 000000000..4c1a65ba2 --- /dev/null +++ b/apps/frameworks/next/agent/channel-auth.ts @@ -0,0 +1,32 @@ +import { type AuthFn, localDev, vercelOidc } from "eve/channels/auth"; +import { getAuthJsSession } from "@/lib/auth"; + +function authjsSession(): AuthFn { + return async (request) => { + const session = await getAuthJsSession(request); + if (!session) return null; + + const attributes: Record = { + providerId: session.providerId, + }; + if (session.profile.email) { + attributes.email = session.profile.email; + } + if (session.profile.name) { + attributes.name = session.profile.name; + } + if (session.profile.image) { + attributes.image = session.profile.image; + } + return { + attributes, + authenticator: "authjs", + issuer: session.issuer, + principalId: session.profile.sub, + principalType: "user", + subject: session.profile.sub, + }; + }; +} + +export const agentChannelAuth = [authjsSession(), localDev(), vercelOidc()] as const; diff --git a/apps/frameworks/next/agent/channels/eve.ts b/apps/frameworks/next/agent/channels/eve.ts index 4a3b240b5..3d72f0b55 100644 --- a/apps/frameworks/next/agent/channels/eve.ts +++ b/apps/frameworks/next/agent/channels/eve.ts @@ -1,35 +1,6 @@ -import { type AuthFn, localDev, vercelOidc } from "eve/channels/auth"; import { eveChannel } from "eve/channels/eve"; -import { getAuthJsSession } from "@/lib/auth"; - -function authjsSession(): AuthFn { - return async (request) => { - const session = await getAuthJsSession(request); - if (!session) return null; - - const attributes: Record = { - providerId: session.providerId, - }; - if (session.profile.email) { - attributes.email = session.profile.email; - } - if (session.profile.name) { - attributes.name = session.profile.name; - } - if (session.profile.image) { - attributes.image = session.profile.image; - } - return { - attributes, - authenticator: "authjs", - issuer: session.issuer, - principalId: session.profile.sub, - principalType: "user", - subject: session.profile.sub, - }; - }; -} +import { agentChannelAuth } from "../channel-auth"; export default eveChannel({ - auth: [authjsSession(), localDev(), vercelOidc()], + auth: agentChannelAuth, }); diff --git a/apps/frameworks/next/agent/channels/realtime-speech.ts b/apps/frameworks/next/agent/channels/realtime-speech.ts new file mode 100644 index 000000000..05cc1bc97 --- /dev/null +++ b/apps/frameworks/next/agent/channels/realtime-speech.ts @@ -0,0 +1,45 @@ +import { createGateway } from "@ai-sdk/gateway"; +import { + vercelSpeechChannel, + type VercelSpeechChannelInput, + type VercelSpeechGetTokenInput, +} from "eve/channels/vercel/speech"; +import { agentChannelAuth } from "../channel-auth"; + +const gatewayBaseUrl = + process.env.AI_GATEWAY_BASE_URL?.trim() || process.env.AI_GATEWAY_BASEURL?.trim(); +const gatewayBypass = + process.env.VERCEL_AUTOMATION_BYPASS_SECRET?.trim() || process.env.VERCEL_DPBP?.trim(); + +const gateway = + gatewayBaseUrl !== undefined && gatewayBaseUrl.length > 0 + ? createGateway({ + baseURL: gatewayBaseUrl, + ...(gatewayBypass !== undefined && gatewayBypass.length > 0 + ? { headers: { "x-vercel-protection-bypass": gatewayBypass } } + : {}), + }) + : undefined; + +function withGatewayBypass(url: string): string { + if (gatewayBypass === undefined || gatewayBypass.length === 0) return url; + const parsed = new URL(url); + parsed.searchParams.set("x-vercel-protection-bypass", gatewayBypass); + return parsed.toString(); +} + +export default vercelSpeechChannel({ + auth: agentChannelAuth, + control: + process.env.EVE_REALTIME_CONTROL === "1" || process.env.NEXT_PUBLIC_EVE_VOICE_CONTROL === "1", + expiresAfterSeconds: 300, + ...(gateway === undefined + ? {} + : { + async getToken(input: VercelSpeechGetTokenInput) { + const token = await gateway.experimental_realtime.getToken(input); + return { ...token, url: withGatewayBypass(token.url) }; + }, + }), + model: process.env.EVE_REALTIME_MODEL?.trim() || "openai/gpt-realtime-2", +} satisfies VercelSpeechChannelInput); diff --git a/apps/frameworks/next/app/_chat/App.tsx b/apps/frameworks/next/app/_chat/App.tsx index fee46a885..5f2f530e3 100644 --- a/apps/frameworks/next/app/_chat/App.tsx +++ b/apps/frameworks/next/app/_chat/App.tsx @@ -1,12 +1,26 @@ "use client"; import { useEveAgent } from "eve/react"; +import { useEveVoice, type EveVoiceMessage } from "eve/react/voice"; import { type FormEvent, type JSX, useEffect, useMemo, useRef, useState } from "react"; -import { traceReducer } from "./trace-reducer"; +import { traceReducer, type TraceProjection } from "./trace-reducer"; import { resolveTurnFailureMessage, shouldRenderAssistantTurn } from "./turn-content"; import type { TraceTurn } from "./types"; +// Demo session tracking sets are unbounded otherwise; cap them so a long +// voice session does not accumulate turn ids and spoken-message keys forever. +const MAX_TRACKED_VOICE_TURNS = 256; + +function rememberBounded(seen: Set, value: string, max: number): void { + seen.add(value); + while (seen.size > max) { + const oldest = seen.values().next().value; + if (oldest === undefined) break; + seen.delete(oldest); + } +} + function ConversationSection(props: { readonly isSending: boolean; readonly turns: readonly TraceTurn[]; @@ -53,24 +67,115 @@ function ConversationSection(props: { ); } +// In Gateway-control mode the durable turn runs server-side (its own `voice:` +// continuation), so the typed-chat feed (`agent.data.turns`) never sees it. The +// hook surfaces the live transcript (user finalized speech + the agent's +// streamed spoken words) as `voice.messages`; this feed renders it, with a +// Thinking… row in the gap while Eve runs the turn. +function VoiceConversationSection(props: { + readonly messages: readonly EveVoiceMessage[]; + readonly thinking: boolean; +}) { + return ( + + ); +} + export function App() { const [composerInput, setComposerInput] = useState(""); const [composerError, setComposerError] = useState(undefined); + const [voiceCaption, setVoiceCaption] = useState(undefined); + const controlMode = process.env.NEXT_PUBLIC_EVE_VOICE_CONTROL === "1"; const conversationStageRef = useRef(null); + const agentRef = useRef> | undefined>(undefined); + const pendingVoiceMessagesRef = useRef([]); const reducer = useMemo(() => traceReducer(), []); + const spokenVoiceMessageKeysRef = useRef(new Set()); + const voiceRef = useRef, "speak"> | undefined>(undefined); + const voiceTurnIdsRef = useRef(new Set()); const agent = useEveAgent({ + onEvent(event) { + if (event.type === "message.received") { + // Correlate by matching the transcript anywhere in the pending queue so + // interleaved typed messages cannot shift the wrong entry off the head. + const pendingIndex = pendingVoiceMessagesRef.current.indexOf(event.data.message); + if (pendingIndex !== -1) { + pendingVoiceMessagesRef.current.splice(pendingIndex, 1); + rememberBounded(voiceTurnIdsRef.current, event.data.turnId, MAX_TRACKED_VOICE_TURNS); + return; + } + } + + if ( + event.type === "message.completed" && + event.data.finishReason !== "tool-calls" && + event.data.message !== null && + voiceTurnIdsRef.current.has(event.data.turnId) + ) { + const key = `${event.data.turnId}:${event.data.stepIndex}:${event.data.message}`; + if (spokenVoiceMessageKeysRef.current.has(key)) return; + + rememberBounded(spokenVoiceMessageKeysRef.current, key, MAX_TRACKED_VOICE_TURNS); + voiceRef.current?.speak(event.data.message); + setVoiceCaption(`Reply ready: ${event.data.message}`); + } + }, reducer, }); + agentRef.current = agent; + const voice = useEveVoice({ + // Opt into Gateway-owned control mode (A-lite): the Gateway drives turns + // over its server-side control socket, so the browser only streams audio + // and `onTranscript` below is not used. Defaults off (client-driven path). + controlMode, + onEvent(event) { + // The live transcript now comes from `voice.messages` / `voice.isThinking` + // (rendered by VoiceConversationSection). Only the client-driven path + // keeps the lightweight caption. + if (!controlMode && event.type === "input-transcription-completed") { + setVoiceCaption(`Heard: ${event.transcript}`); + } + }, + async onTranscript({ transcript }) { + setVoiceCaption(`Heard: ${transcript}`); + pendingVoiceMessagesRef.current.push(transcript); + try { + await agentRef.current?.send({ message: transcript }); + } catch (error) { + const index = pendingVoiceMessagesRef.current.indexOf(transcript); + if (index !== -1) pendingVoiceMessagesRef.current.splice(index, 1); + throw error; + } + }, + }); + voiceRef.current = voice; const turns = agent.data.turns; const isComposeInProgress = agent.status === "submitted" || agent.status === "streaming"; const hasComposerText = composerInput.trim().length > 0; - const hasConversation = turns.length > 0 || isComposeInProgress; + const hasVoiceConversation = controlMode && (voice.messages.length > 0 || voice.isThinking); + const hasConversation = turns.length > 0 || isComposeInProgress || hasVoiceConversation; const conversationActivityKey = [ agent.session.sessionId ?? "new-thread", String(agent.session.streamIndex), String(agent.events.length), agent.status, + String(voice.messages.length), + String(voice.messages.at(-1)?.text.length ?? 0), + String(voice.isThinking), ].join(":"); useEffect(() => { @@ -136,7 +241,27 @@ export function App() { value={composerInput} />
+ {voiceCaption !== undefined ?

{voiceCaption}

: }
+
); } + +function VoiceGlyph(props: { readonly activity: ReturnType["activity"] }) { + if (props.activity === "assistant-speaking") { + return ( + + ); + } + + if (props.activity === "user-speaking") { + return ( + + ); + } + + return ( + + ); +} + +function voiceButtonLabel(activity: ReturnType["activity"]): string { + switch (activity) { + case "assistant-speaking": + return "Stop voice; assistant is speaking"; + case "connecting": + return "Connecting voice"; + case "error": + return "Voice unavailable"; + case "listening": + return "Stop voice; listening"; + case "user-speaking": + return "Stop voice; speech detected"; + case "ready": + return "Start voice"; + } + return "Start voice"; +} diff --git a/apps/frameworks/next/app/_chat/styles.css b/apps/frameworks/next/app/_chat/styles.css index 4b34f6203..6b0ec2d96 100644 --- a/apps/frameworks/next/app/_chat/styles.css +++ b/apps/frameworks/next/app/_chat/styles.css @@ -296,6 +296,19 @@ pre { margin-left: auto; } +.voice-caption { + color: var(--fg-muted); + flex: 1; + font-size: 12px; + line-height: 1.35; + margin: 0; + min-width: 0; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + .send-button { background: none; border: 1px solid var(--border); @@ -307,6 +320,88 @@ pre { width: 32px; } +.voice-toggle-button { + align-items: center; + background: none; + border: 1px solid var(--border); + border-radius: 50%; + color: var(--fg-muted); + cursor: pointer; + display: inline-flex; + height: 32px; + justify-content: center; + padding: 0; + position: relative; + transition: + background 160ms ease, + border-color 160ms ease, + box-shadow 160ms ease, + color 160ms ease, + opacity 160ms ease; + width: 32px; +} + +.voice-toggle-button[data-voice-state="connecting"], +.voice-toggle-button[data-voice-state="listening"], +.voice-toggle-button[data-voice-state="user-speaking"], +.voice-toggle-button[data-voice-state="assistant-speaking"] { + background: var(--accent); + border-color: var(--accent); + color: var(--fg-on-inverted); +} + +.voice-toggle-button[data-voice-state="listening"] { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 10%, transparent); +} + +.voice-toggle-button[data-voice-state="user-speaking"] { + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent), + 0 0 0 9px color-mix(in srgb, var(--accent) 8%, transparent); +} + +.voice-toggle-button[data-voice-state="user-speaking"]::after { + animation: voice-pulse 1.1s ease-out infinite; + border: 1px solid color-mix(in srgb, var(--accent) 34%, transparent); + border-radius: inherit; + content: ""; + inset: -6px; + pointer-events: none; + position: absolute; +} + +.voice-toggle-button[data-voice-state="assistant-speaking"] { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 18%, transparent); +} + +.voice-toggle-button[data-voice-state="error"] { + border-color: color-mix(in srgb, var(--danger) 42%, var(--border)); + color: var(--danger); +} + +.voice-toggle-button:hover:enabled { + color: var(--fg); +} + +.voice-toggle-button[data-voice-state="connecting"]:hover:enabled, +.voice-toggle-button[data-voice-state="listening"]:hover:enabled, +.voice-toggle-button[data-voice-state="user-speaking"]:hover:enabled, +.voice-toggle-button[data-voice-state="assistant-speaking"]:hover:enabled { + color: var(--fg-on-inverted); + opacity: 0.85; +} + +@keyframes voice-pulse { + from { + opacity: 0.72; + transform: scale(0.9); + } + to { + opacity: 0; + transform: scale(1.22); + } +} + .send-button.ready { background: var(--accent); border-color: var(--accent); diff --git a/apps/frameworks/next/package.json b/apps/frameworks/next/package.json index a7e57adc0..47da60295 100644 --- a/apps/frameworks/next/package.json +++ b/apps/frameworks/next/package.json @@ -12,6 +12,8 @@ "typecheck": "tsc" }, "dependencies": { + "@ai-sdk/gateway": "catalog:", + "@ai-sdk/react": "catalog:", "@auth/core": "0.41.2", "@vercel/connect": "catalog:", "@vercel/oidc": "3.4.1", diff --git a/docs/channels/meta.json b/docs/channels/meta.json index 32cb56fa4..83924619d 100644 --- a/docs/channels/meta.json +++ b/docs/channels/meta.json @@ -3,6 +3,7 @@ "pages": [ "overview", "eve", + "realtime-speech", "slack", "discord", "teams", diff --git a/docs/channels/realtime-speech.mdx b/docs/channels/realtime-speech.mdx new file mode 100644 index 000000000..1ced36671 --- /dev/null +++ b/docs/channels/realtime-speech.mdx @@ -0,0 +1,152 @@ +--- +title: "Realtime Speech (Vercel AI Gateway)" +description: "Add a browser microphone backed by Vercel AI Gateway realtime audio; finalized utterances run as normal durable Eve turns." +--- + +Realtime Speech adds a microphone surface for agents without turning Eve into a long-running audio runtime. + +This channel is specific to **Vercel AI Gateway** realtime audio and is exported from `eve/channels/vercel/speech`. It is not a provider-agnostic speech API. The shipped topology is: + +- **Audio:** browser ↔ AI Gateway realtime WebSocket (Eve is never in the audio path). +- **Turns:** browser/app ↔ Eve `/eve/v1/session` (+ `/stream`) as ordinary durable turns. + +Eve serves a setup route that authenticates the caller and mints a short-lived Gateway client secret for the browser audio socket; each finalized utterance then becomes one normal durable agent turn through the existing session API. + +`useEveVoice` is audio-first by default: it requests audio output from the realtime model and uses transcription events for visible text. Override `sessionConfig.outputModalities` only if you intentionally want a text-only or provider-specific realtime mode. + +## Install + +The server channel ships with Eve. The React microphone helper uses the AI SDK realtime hook, so install the optional peer in apps that render voice UI: + +```bash +npm install eve@latest @ai-sdk/react +``` + +## Enable the channel + +Create `agent/channels/speech.ts`: + +```ts +import { vercelSpeechChannel } from "eve/channels/vercel/speech"; +import { localDev, vercelOidc } from "eve/channels/auth"; + +export default vercelSpeechChannel({ + auth: [localDev(), vercelOidc()], +}); +``` + +Provide `AI_GATEWAY_API_KEY` in the server environment. The setup route uses it to mint a single-use `vcst_` realtime client secret, so the browser never receives a long-lived Gateway credential. + +## Add a microphone + +Use the React hook to render a microphone control that fits your UI: + +```tsx +"use client"; + +import { useEveVoice } from "eve/react/voice"; + +export function ComposerActions() { + const voice = useEveVoice(); + const active = voice.status === "connected" || voice.status === "connecting"; + + return ( + + ); +} +``` + +The React helper is intentionally limited to React because it wraps the AI SDK realtime React hook. For non-React clients, mint the realtime audio token with `setupVoice` and run turns through a normal durable session: + +```ts +import { Client, setupVoice } from "eve/client"; + +const client = new Client({ host: "https://my-agent.example.com" }); + +// Mint the short-lived AI Gateway realtime token to open the audio socket. +const { token, url, voiceSessionId } = await setupVoice(client); + +// Finalized transcripts are ordinary durable turns. +const session = client.session(); +const reply = await session.send("What's the weather?").result(); +console.log(reply.message); +``` + +## How it works + +- `POST /eve/v1/realtime-speech/setup` authenticates the request and returns a Gateway realtime token, an empty realtime tool list, and `voiceSessionId`. +- The browser opens the AI Gateway realtime WebSocket directly with the short-lived token. +- The client listens for finalized realtime transcription events and sends each transcript to Eve as a normal durable turn through `POST /eve/v1/session` (and `POST /eve/v1/session/:sessionId` for follow-ups), which return immediately. +- The client consumes the session event stream (`GET /eve/v1/session/:sessionId/stream`) and, on a non-tool-call `message.completed`, sends that text back to the realtime session for audio playback. +- The durable continuation token returned by the session route keeps the same Eve conversation across utterances, advancing the stream cursor each turn. + +No Eve HTTP request blocks for a full model turn: the setup POST and each session turn POST return right away, and replies arrive over the event stream. The speech transport can stay open for many utterances. Eve still sees discrete durable turns and parks between them, so history, tools, compaction, auth, and instrumentation behave the same as other channels. + +Eve is the durable source of truth for the conversation: transcripts enter Eve as user turns, and spoken replies are readback of Eve output. Realtime provider suppression is still client/provider mediated, so do not treat the realtime model as a security boundary for policy decisions. + +## Options + +```ts +export default vercelSpeechChannel({ + auth: [localDev(), vercelOidc()], + model: "openai/gpt-realtime-2", + expiresAfterSeconds: 300, +}); +``` + +`useEveVoice` defaults to the `/eve/v1/realtime-speech/setup` route and same-origin `/eve/v1/session` routes. Override `setupUrl` only if you changed the channel `basePath`, and pass `host`, `auth`, `headers`, `client`, or `session` to run turns against a custom origin or a session you already manage (for example one shared with `useEveAgent`). + +The default client session config uses `outputModalities: ["audio"]`, `inputAudioTranscription: {}`, and `outputAudioTranscription: {}`. This keeps the speech UX compatible with realtime providers that reject mixed `audio` + `text` output modalities while still letting the UI observe transcripts. + +`sessionConfig` is merged over the defaults, so passing `instructions` replaces the built-in speech-adapter prompt that drives reply playback (it tells the model to speak only the text after the `EVE_SPEAK:` marker). If you override `instructions`, keep that marker behavior or Eve's replies will not be spoken. + +## Gateway-owned control mode (A-lite, experimental) + +In the default mode above the client (or app) owns turn timing. The experimental **Gateway-owned control mode** instead lets AI Gateway drive turns: per live session it dials an Eve `WS()` control route with a signed, short-lived control token; Eve verifies it, owns turn coordination (settle-debounce, backchannel/duplicate suppression, barge-in), runs durable turns, and streams reply text back as semantic `response.delta` / `response.done` packets that Gateway injects into the provider's TTS. Audio still never flows through Eve, and the browser is frame-filtered by Gateway so only Eve can trigger speech. + +This requires the matching AI Gateway control feature to be enabled for your team. Enable it on the channel: + +```ts +export default vercelSpeechChannel({ + auth: [localDev(), vercelOidc()], + control: true, +}); +``` + +and put the browser hook in control mode so it only streams audio (no client-driven turns): + +```tsx +const voice = useEveVoice({ controlMode: true }); +``` + +`control: true` makes `/setup` mint a token carrying an Eve control URL + signed control token, and serves the `{basePath}/ws` route Gateway dials. Put the browser hook in `controlMode` only when the channel is also configured with `control`; otherwise the browser and Gateway will disagree about who owns Eve turns. + +Configuration: + +- **`EVE_REALTIME_CONTROL_URL`** — full `wss://` (or `ws://localhost`) control URL. For local dev, expose Eve via a tunnel (ngrok/cloudflared) or a preview deployment and point this at `wss:///eve/v1/realtime-speech/ws`. Otherwise Eve derives it from the deployment host (`VERCEL_BRANCH_URL` / `VERCEL_URL`). +- **`EVE_REALTIME_CONTROL_SECRET`** — HMAC secret for control tokens. Required by default. For local/preview experiments only, set `EVE_REALTIME_CONTROL_ALLOW_GATEWAY_KEY_FALLBACK=1` or pass `allowGatewayKeyFallback: true` to derive it from `AI_GATEWAY_API_KEY`. +- **`VERCEL_AUTOMATION_BYPASS_SECRET`** (or `VERCEL_DPBP`) — when set, appended to Vercel deployment control URLs as `x-vercel-protection-bypass` so Gateway can dial a protection-enabled preview. Temporary, for protected-preview testing. + +Control mode keeps a voice-session continuation token and stream cursor keyed by the authenticated principal plus `voiceSessionId`. The channel uses an in-memory store by default, which helps same-instance reconnects but is not durable across serverless cold starts. For production recovery, pass a durable store: + +```ts +export default vercelSpeechChannel({ + auth: [localDev(), vercelOidc()], + control: { + stateStore: { + async get(key) { + return await loadVoiceState(key); + }, + async set(key, state) { + await saveVoiceState(key, state); + }, + }, + }, +}); +``` + +If you want recovery across page reloads, persist and reuse the same `voiceSessionId` in `useEveVoice({ voiceSessionId, controlMode: true })` or `setupVoice(..., { voiceSessionId })`. + +Eve degrades gracefully: it uses final transcripts (partials are optional), only promises barge-in when Gateway sends interruption signals, and still runs durable turns even if the provider cannot speak. The default durable-session path remains available and is the recommended fallback. diff --git a/packages/eve-catalog/src/index.ts b/packages/eve-catalog/src/index.ts index 07682f226..8fb86cad2 100644 --- a/packages/eve-catalog/src/index.ts +++ b/packages/eve-catalog/src/index.ts @@ -143,6 +143,13 @@ export const INTEGRATIONS: readonly IntegrationEntry[] = [ tagline: "Embed a first-party web chat UI backed by your agent.", surfaces: { scaffoldable: true, gallery: true }, }, + { + slug: "realtime-speech", + name: "Realtime Speech", + kind: "channel", + tagline: "Add a microphone button backed by AI Gateway realtime audio and durable Eve turns.", + surfaces: { scaffoldable: false, gallery: true }, + }, { slug: "linear", name: "Linear", diff --git a/packages/eve/package.json b/packages/eve/package.json index e1c1c1cee..f4955f1ff 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -66,11 +66,21 @@ "import": "./dist/src/client/index.js", "default": "./dist/src/client/index.js" }, + "./client/voice": { + "types": "./dist/src/client/voice.d.ts", + "import": "./dist/src/client/voice.js", + "default": "./dist/src/client/voice.js" + }, "./react": { "types": "./dist/src/react/index.d.ts", "import": "./dist/src/react/index.js", "default": "./dist/src/react/index.js" }, + "./react/voice": { + "types": "./dist/src/react/voice.d.ts", + "import": "./dist/src/react/voice.js", + "default": "./dist/src/react/voice.js" + }, "./vue": { "types": "./dist/src/vue/index.d.ts", "import": "./dist/src/vue/index.js", @@ -206,6 +216,11 @@ "import": "./dist/src/public/channels/eve.js", "default": "./dist/src/public/channels/eve.js" }, + "./channels/vercel/speech": { + "types": "./dist/src/public/channels/vercel/speech.d.ts", + "import": "./dist/src/public/channels/vercel/speech.js", + "default": "./dist/src/public/channels/vercel/speech.js" + }, "./channels/auth": { "types": "./dist/src/public/channels/auth.d.ts", "import": "./dist/src/public/channels/auth.js", @@ -288,11 +303,13 @@ }, "devDependencies": { "@ai-sdk/anthropic": "catalog:", + "@ai-sdk/gateway": "catalog:", "@ai-sdk/google": "catalog:", "@ai-sdk/mcp": "catalog:", "@ai-sdk/openai": "catalog:", "@ai-sdk/otel": "catalog:", "@ai-sdk/provider": "catalog:", + "@ai-sdk/react": "catalog:", "@chat-adapter/slack": "4.29.0", "@chat-adapter/state-memory": "4.29.0", "@clack/core": "1.3.1", @@ -336,6 +353,8 @@ "zod-validation-error": "5.0.0" }, "peerDependencies": { + "@ai-sdk/gateway": "catalog:", + "@ai-sdk/react": "catalog:", "@opentelemetry/api": "^1.0.0", "@sveltejs/kit": "^2.0.0", "ai": "catalog:", @@ -350,6 +369,12 @@ "vue": "^3.5.0" }, "peerDependenciesMeta": { + "@ai-sdk/gateway": { + "optional": true + }, + "@ai-sdk/react": { + "optional": true + }, "@opentelemetry/api": { "optional": true }, diff --git a/packages/eve/scripts/build-rolldown.mjs b/packages/eve/scripts/build-rolldown.mjs index 30f10e597..a04bc9ac1 100644 --- a/packages/eve/scripts/build-rolldown.mjs +++ b/packages/eve/scripts/build-rolldown.mjs @@ -125,8 +125,9 @@ const EXCLUDED_DIRECTORIES = new Set([join("internal", "testing")]); * Packages externalized at bundle time so rolldown never inlines them * into eve's dist tree. Three categories: * - * - Peer dependencies (`ai`, `next`, `react`, `@opentelemetry/api`, - * `braintrust`) — consumers provide the install. + * - Peer dependencies (`ai`, `next`, `react`, `@ai-sdk/gateway`, + * `@ai-sdk/react`, `@opentelemetry/api`, `braintrust`) — consumers + * provide the install. * - Runtime dependencies (`nitro`) — resolved at * runtime against the eve installation. * - Optional peer dependency (`just-bash`) — the opt-in local sandbox @@ -138,6 +139,8 @@ const EXCLUDED_DIRECTORIES = new Set([join("internal", "testing")]); * the package `imports` map. */ const EXTERNAL_PACKAGES = new Set([ + "@ai-sdk/gateway", + "@ai-sdk/react", "@nuxt/kit", "@opentelemetry/api", "@sveltejs/kit", diff --git a/packages/eve/src/client/index.ts b/packages/eve/src/client/index.ts index 24744f10d..0c7c1ff63 100644 --- a/packages/eve/src/client/index.ts +++ b/packages/eve/src/client/index.ts @@ -9,6 +9,7 @@ export { defaultMessageReducer } from "#client/message-reducer.js"; export { createDataUrlFilePart, createTextWithFileContent } from "#client/file-parts.js"; export { MessageResponse } from "#client/message-response.js"; export { ClientSession } from "#client/session.js"; +export { EVE_VOICE_SETUP_ROUTE_PATH, setupVoice, voiceSetupUrl } from "#client/voice.js"; // --------------------------------------------------------------------------- // Client types @@ -52,6 +53,7 @@ export type { StreamOptions, TokenValue, } from "#client/types.js"; +export type { EveVoiceSetupResult, SetupVoiceOptions, VoiceTokenClient } from "#client/voice.js"; export type { EveAgentReducer, diff --git a/packages/eve/src/client/url.test.ts b/packages/eve/src/client/url.test.ts index b0df8c1c0..abb44984d 100644 --- a/packages/eve/src/client/url.test.ts +++ b/packages/eve/src/client/url.test.ts @@ -24,4 +24,19 @@ describe("createClientUrl", () => { "/api/eve/v1/session/123/stream?startIndex=4", ); }); + + it("preserves a query string embedded in the route path for absolute hosts", () => { + expect( + createClientUrl( + "https://agent.example.com", + "/eve/v1/realtime-speech/setup?voiceSessionId=v1", + ), + ).toBe("https://agent.example.com/eve/v1/realtime-speech/setup?voiceSessionId=v1"); + }); + + it("preserves a query string embedded in the route path for same-origin prefixes", () => { + expect(createClientUrl("", "/eve/v1/realtime-speech/setup?voiceSessionId=v1")).toBe( + "/eve/v1/realtime-speech/setup?voiceSessionId=v1", + ); + }); }); diff --git a/packages/eve/src/client/url.ts b/packages/eve/src/client/url.ts index 4f49be6cd..ce8db4c07 100644 --- a/packages/eve/src/client/url.ts +++ b/packages/eve/src/client/url.ts @@ -10,8 +10,17 @@ export function createClientUrl( routePath: string, searchParams?: Readonly>, ): string { - const normalizedRoute = routePath.startsWith("/") ? routePath : `/${routePath}`; - const search = formatSearch(searchParams); + const queryIndex = routePath.indexOf("?"); + const pathPart = queryIndex === -1 ? routePath : routePath.slice(0, queryIndex); + const embeddedSearch = queryIndex === -1 ? "" : routePath.slice(queryIndex); + const normalizedRoute = pathPart.startsWith("/") ? pathPart : `/${pathPart}`; + // Explicit searchParams win; otherwise preserve a query string already on the + // route path (e.g. the realtime-speech setup URL's `?voiceSessionId=`), which + // the URL pathname setter would otherwise percent-encode for absolute hosts. + const search = + searchParams && Object.keys(searchParams).length > 0 + ? formatSearch(searchParams) + : embeddedSearch; if (isAbsoluteUrl(host)) { const url = new URL(host); diff --git a/packages/eve/src/client/voice.test.ts b/packages/eve/src/client/voice.test.ts new file mode 100644 index 000000000..736290690 --- /dev/null +++ b/packages/eve/src/client/voice.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; + +import { Client } from "#client/client.js"; +import { setupVoice, voiceSetupUrl } from "#client/voice.js"; + +describe("voiceSetupUrl", () => { + it("appends the voice session id to a relative setup route", () => { + expect(voiceSetupUrl("/eve/v1/realtime-speech/setup", "voice-1")).toBe( + "/eve/v1/realtime-speech/setup?voiceSessionId=voice-1", + ); + }); + + it("appends the voice session id to an absolute setup route", () => { + expect(voiceSetupUrl("https://eve.example.com/eve/v1/realtime-speech/setup", "voice-1")).toBe( + "https://eve.example.com/eve/v1/realtime-speech/setup?voiceSessionId=voice-1", + ); + }); +}); + +describe("setupVoice", () => { + it("mints a realtime token through the setup route with the voice session id", async () => { + const fetch = vi.fn(async () => + Response.json({ + expiresAt: 1_700_000_060, + token: "vcst_test", + url: "wss://gateway.example/realtime-model", + voiceSessionId: "voice-1", + }), + ); + + const result = await setupVoice({ fetch }, { voiceSessionId: "voice-1" }); + + expect(fetch).toHaveBeenCalledWith( + "/eve/v1/realtime-speech/setup?voiceSessionId=voice-1", + expect.objectContaining({ method: "POST" }), + ); + expect(result).toEqual({ + expiresAt: 1_700_000_060, + token: "vcst_test", + url: "wss://gateway.example/realtime-model", + voiceSessionId: "voice-1", + }); + }); + + it("throws when the setup response is malformed", async () => { + const fetch = vi.fn(async () => Response.json({ token: "vcst_test" })); + await expect(setupVoice({ fetch }, { voiceSessionId: "voice-1" })).rejects.toThrow(/malformed/); + }); + + it("works against an authenticated Eve client and a remote host", async () => { + const fetch = vi.fn(async () => + Response.json({ + token: "vcst_client", + url: "wss://gateway.example/realtime-model", + voiceSessionId: "voice-client", + }), + ); + vi.stubGlobal("fetch", fetch); + + const client = new Client({ auth: { bearer: "test-token" }, host: "https://eve.example.com" }); + await setupVoice(client, { voiceSessionId: "voice-client" }); + + expect(fetch).toHaveBeenCalledWith( + "https://eve.example.com/eve/v1/realtime-speech/setup?voiceSessionId=voice-client", + expect.objectContaining({ method: "POST" }), + ); + const headers = (fetch as ReturnType).mock.calls[0]![1].headers as Headers; + expect(headers.get("authorization")).toBe("Bearer test-token"); + + vi.unstubAllGlobals(); + }); +}); diff --git a/packages/eve/src/client/voice.ts b/packages/eve/src/client/voice.ts new file mode 100644 index 000000000..e635fa718 --- /dev/null +++ b/packages/eve/src/client/voice.ts @@ -0,0 +1,93 @@ +export const EVE_VOICE_SETUP_ROUTE_PATH = "/eve/v1/realtime-speech/setup"; + +export interface EveVoiceSetupResult { + readonly control?: boolean; + readonly expiresAt?: number; + readonly token: string; + readonly url: string; + readonly voiceSessionId: string; +} + +/** + * Minimal authenticated-fetch surface needed to mint a realtime voice token. + * + * {@link import("#client/client.js").Client} satisfies this, but the helper + * stays decoupled so it can run against any same-auth transport. + */ +export interface VoiceTokenClient { + fetch(path: string, init?: RequestInit): Promise; +} + +export interface SetupVoiceOptions { + /** Override the setup route when the channel uses a custom `basePath`. */ + readonly setupUrl?: string; + /** Reuse an existing voice session id instead of letting the server mint one. */ + readonly voiceSessionId?: string; +} + +/** + * Appends the voice session id to a realtime-speech setup URL. + * + * Works for both relative same-origin routes (`/eve/v1/realtime-speech/setup`) + * and absolute origins. The realtime audio socket and Gateway usage attribution + * are keyed by this id; durable Eve turns are bound to the authenticated + * principal by normal session-route auth, not by this value. + */ +export function voiceSetupUrl(baseUrl: string, voiceSessionId: string): string { + const absolute = /^https?:\/\//u.test(baseUrl); + const parsed = new URL(baseUrl, "https://eve.local"); + parsed.searchParams.set("voiceSessionId", voiceSessionId); + if (absolute) return parsed.toString(); + return `${parsed.pathname}${parsed.search}${parsed.hash}`; +} + +/** + * Mints a short-lived AI Gateway realtime token for a voice client. + * + * This is the one genuinely voice-specific concern that the durable session + * API does not cover: opening the browser/audio socket to AI Gateway. Run + * normal turns with {@link import("#client/session.js").ClientSession} via + * `client.session().send(...)`; use this only to obtain the realtime audio + * token and `voiceSessionId`. + */ +export async function setupVoice( + client: VoiceTokenClient, + options: SetupVoiceOptions = {}, +): Promise { + const voiceSessionId = options.voiceSessionId ?? crypto.randomUUID(); + const url = voiceSetupUrl(options.setupUrl ?? EVE_VOICE_SETUP_ROUTE_PATH, voiceSessionId); + + const response = await client.fetch(url, { + headers: { "content-type": "application/json" }, + method: "POST", + }); + const data = (await response.json().catch(() => ({}))) as Partial & { + readonly error?: unknown; + }; + if (!response.ok) { + throw new Error(typeof data.error === "string" ? data.error : "Eve voice setup failed."); + } + if (typeof data.token !== "string" || typeof data.url !== "string") { + throw new Error("Eve voice setup response was malformed."); + } + + const resolvedVoiceSessionId = + typeof data.voiceSessionId === "string" && data.voiceSessionId.length > 0 + ? data.voiceSessionId + : voiceSessionId; + + const result: { + control?: boolean; + expiresAt?: number; + token: string; + url: string; + voiceSessionId: string; + } = { + token: data.token, + url: data.url, + voiceSessionId: resolvedVoiceSessionId, + }; + if (typeof data.control === "boolean") result.control = data.control; + if (typeof data.expiresAt === "number") result.expiresAt = data.expiresAt; + return result; +} diff --git a/packages/eve/src/public/channels/index.ts b/packages/eve/src/public/channels/index.ts index e3a1ded98..cbbbf5b38 100644 --- a/packages/eve/src/public/channels/index.ts +++ b/packages/eve/src/public/channels/index.ts @@ -32,6 +32,14 @@ export { createWebSocketUpgradeServer, type WebSocketUpgradeServerBridge, } from "#channel/websocket-upgrade-server.js"; +export { + vercelSpeechChannel, + type VercelRealtimeClientSecret, + type VercelRealtimeControlConfig, + type VercelSpeechChannelInput, + type VercelSpeechControlInput, + type VercelSpeechSetupResponse, +} from "#public/channels/vercel/speech.js"; import { getChannelInstrumentationKind } from "#channel/compiled-channel.js"; import type { Channel } from "#public/definitions/defineChannel.js"; diff --git a/packages/eve/src/public/channels/vercel/control-token.test.ts b/packages/eve/src/public/channels/vercel/control-token.test.ts new file mode 100644 index 000000000..a2815315f --- /dev/null +++ b/packages/eve/src/public/channels/vercel/control-token.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import type { SessionAuthContext } from "#channel/types.js"; +import { + createControlToken, + resolveControlSecret, + verifyControlToken, +} from "#public/channels/vercel/control-token.js"; + +const auth: SessionAuthContext = { + attributes: { team: "acme" }, + authenticator: "test", + issuer: "test-idp", + principalId: "user-1", + principalType: "user", + subject: "user-1", +}; + +const secret = "test-control-secret"; + +afterEach(() => { + delete process.env.EVE_REALTIME_CONTROL_SECRET; + delete process.env.AI_GATEWAY_API_KEY; +}); + +describe("control token", () => { + it("round-trips the principal and voice session id", async () => { + const token = await createControlToken({ + auth, + voiceSessionId: "voice-1", + ttlSeconds: 600, + secret, + }); + const result = await verifyControlToken(token, { secret }); + + expect(result).toEqual({ ok: true, auth, voiceSessionId: "voice-1" }); + }); + + it("rejects a tampered token", async () => { + const token = await createControlToken({ + auth, + voiceSessionId: "voice-1", + ttlSeconds: 600, + secret, + }); + const [prefix, body, sig] = token.split("."); + const tampered = `${prefix}.${body}x.${sig}`; + + expect(await verifyControlToken(tampered, { secret })).toMatchObject({ ok: false }); + }); + + it("rejects a token signed with a different secret", async () => { + const token = await createControlToken({ + auth, + voiceSessionId: "voice-1", + ttlSeconds: 600, + secret, + }); + expect(await verifyControlToken(token, { secret: "other" })).toEqual({ + ok: false, + reason: "bad_signature", + }); + }); + + it("rejects an expired token", async () => { + const token = await createControlToken({ + auth, + voiceSessionId: "voice-1", + ttlSeconds: 1, + secret, + now: 1_000_000, + }); + expect(await verifyControlToken(token, { secret, now: 1_000_000 + 5_000 })).toEqual({ + ok: false, + reason: "expired", + }); + }); + + it("rejects a missing token", async () => { + expect(await verifyControlToken(undefined, { secret })).toEqual({ + ok: false, + reason: "missing_token", + }); + }); + + it("derives a fallback secret from AI_GATEWAY_API_KEY", () => { + process.env.AI_GATEWAY_API_KEY = "gw-key-123"; + const resolved = resolveControlSecret(undefined, { allowGatewayKeyFallback: true }); + expect(resolved).toBe("eve-realtime-control:gw-key-123"); + expect(resolved).not.toBe("gw-key-123"); + }); + + it("does not derive from AI_GATEWAY_API_KEY unless explicitly allowed", () => { + process.env.AI_GATEWAY_API_KEY = "gw-key-123"; + expect(() => resolveControlSecret()).toThrow(/signing secret/); + }); + + it("prefers EVE_REALTIME_CONTROL_SECRET over the gateway key", () => { + process.env.AI_GATEWAY_API_KEY = "gw-key-123"; + process.env.EVE_REALTIME_CONTROL_SECRET = "dedicated"; + expect(resolveControlSecret()).toBe("dedicated"); + }); + + it("throws when no secret is available", () => { + expect(() => resolveControlSecret()).toThrow(/signing secret/); + }); +}); diff --git a/packages/eve/src/public/channels/vercel/control-token.ts b/packages/eve/src/public/channels/vercel/control-token.ts new file mode 100644 index 000000000..46a49cbdb --- /dev/null +++ b/packages/eve/src/public/channels/vercel/control-token.ts @@ -0,0 +1,189 @@ +import type { SessionAuthContext } from "#channel/types.js"; + +/** + * Stateless, HMAC-signed control token for the Gateway-owned realtime voice + * control socket. + * + * Eve mints this at `/setup` (carrying the authenticated principal and the + * `voiceSessionId`) and hands it to AI Gateway as the realtime `control.token`. + * Gateway later dials Eve's `WS()` control route and presents it as + * `Authorization: Bearer `. Because the mint and the WS upgrade are + * different (serverless) invocations with no shared per-session store, the token + * is self-verifying: the upgrade recomputes the HMAC and checks expiry/audience + * rather than looking the secret up. The signature is the unforgeable capability + * that authorizes Gateway to drive durable turns as the bound principal. + */ + +const TOKEN_PREFIX = "evc1"; +const AUDIENCE = "eve-voice-control"; + +interface ControlTokenPayload { + readonly aud: typeof AUDIENCE; + /** Expiry, epoch seconds. */ + readonly exp: number; + /** Issued-at, epoch seconds. */ + readonly iat: number; + /** Authenticated principal the durable turns run as. */ + readonly auth: SessionAuthContext; + /** Client-visible voice session correlation id. */ + readonly vsid: string; +} + +export interface CreateControlTokenInput { + readonly auth: SessionAuthContext; + readonly voiceSessionId: string; + readonly ttlSeconds: number; + readonly secret: string; + readonly now?: number; +} + +export interface ResolveControlSecretOptions { + /** + * Allows preview/local setups to derive the signing secret from + * `AI_GATEWAY_API_KEY`. Disabled by default so the Gateway credential is not + * also the Eve control-plane signing root in production. + */ + readonly allowGatewayKeyFallback?: boolean; +} + +export type VerifyControlTokenResult = + | { readonly ok: true; readonly auth: SessionAuthContext; readonly voiceSessionId: string } + | { readonly ok: false; readonly reason: string }; + +/** + * Resolves the HMAC signing secret. Prefers an explicit value, then + * `EVE_REALTIME_CONTROL_SECRET`. Preview/dev can opt into a domain-separated + * derivation from `AI_GATEWAY_API_KEY`, but production should use a dedicated + * control-plane secret. + */ +export function resolveControlSecret( + explicit?: string, + options: ResolveControlSecretOptions = {}, +): string { + const allowGatewayKeyFallback = + options.allowGatewayKeyFallback === true || + process.env.EVE_REALTIME_CONTROL_ALLOW_GATEWAY_KEY_FALLBACK === "1"; + const candidate = + readNonEmpty(explicit) ?? + readNonEmpty(process.env.EVE_REALTIME_CONTROL_SECRET) ?? + (allowGatewayKeyFallback + ? deriveFallbackSecret(readNonEmpty(process.env.AI_GATEWAY_API_KEY)) + : undefined); + if (candidate === undefined) { + throw new Error( + "Eve realtime voice control requires a signing secret. Set EVE_REALTIME_CONTROL_SECRET.", + ); + } + return candidate; +} + +/** Signs a control token binding the principal + voice session id, with expiry. */ +export async function createControlToken(input: CreateControlTokenInput): Promise { + const iat = Math.floor((input.now ?? Date.now()) / 1000); + const payload: ControlTokenPayload = { + aud: AUDIENCE, + exp: iat + Math.max(1, Math.floor(input.ttlSeconds)), + iat, + auth: input.auth, + vsid: input.voiceSessionId, + }; + const body = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload))); + const signature = await sign(`${TOKEN_PREFIX}.${body}`, input.secret); + return `${TOKEN_PREFIX}.${body}.${signature}`; +} + +/** Verifies signature, audience, and expiry; returns the bound principal. */ +export async function verifyControlToken( + token: string | undefined | null, + input: { readonly secret: string; readonly now?: number }, +): Promise { + if (typeof token !== "string" || token.length === 0) { + return { ok: false, reason: "missing_token" }; + } + const parts = token.split("."); + if (parts.length !== 3 || parts[0] !== TOKEN_PREFIX) { + return { ok: false, reason: "malformed_token" }; + } + const [, body, signature] = parts; + const expected = await sign(`${TOKEN_PREFIX}.${body}`, input.secret); + if (!timingSafeEqual(signature ?? "", expected)) { + return { ok: false, reason: "bad_signature" }; + } + + let payload: ControlTokenPayload; + try { + payload = JSON.parse( + new TextDecoder().decode(base64UrlDecode(body ?? "")), + ) as ControlTokenPayload; + } catch { + return { ok: false, reason: "malformed_payload" }; + } + if (payload.aud !== AUDIENCE) return { ok: false, reason: "bad_audience" }; + + const now = Math.floor((input.now ?? Date.now()) / 1000); + if (typeof payload.exp !== "number" || payload.exp < now) { + return { ok: false, reason: "expired" }; + } + if (!isSessionAuthContext(payload.auth) || typeof payload.vsid !== "string") { + return { ok: false, reason: "malformed_payload" }; + } + + return { ok: true, auth: payload.auth, voiceSessionId: payload.vsid }; +} + +async function sign(message: string, secret: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message)); + return base64UrlEncode(new Uint8Array(digest)); +} + +function deriveFallbackSecret(apiKey: string | undefined): string | undefined { + if (apiKey === undefined) return undefined; + // Domain-separate so the control-token secret is never byte-identical to the + // Gateway credential, even though it is derived from it. + return `eve-realtime-control:${apiKey}`; +} + +function isSessionAuthContext(value: unknown): value is SessionAuthContext { + return ( + value !== null && + typeof value === "object" && + typeof (value as SessionAuthContext).principalId === "string" && + typeof (value as SessionAuthContext).principalType === "string" && + typeof (value as SessionAuthContext).authenticator === "string" + ); +} + +function readNonEmpty(value: string | undefined): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let mismatch = 0; + for (let i = 0; i < a.length; i += 1) { + mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return mismatch === 0; +} + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/u, ""); +} + +function base64UrlDecode(value: string): Uint8Array { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); + const binary = atob(padded); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +} diff --git a/packages/eve/src/public/channels/vercel/control-url.test.ts b/packages/eve/src/public/channels/vercel/control-url.test.ts new file mode 100644 index 000000000..add8c0926 --- /dev/null +++ b/packages/eve/src/public/channels/vercel/control-url.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { resolveControlUrl } from "#public/channels/vercel/control-url.js"; + +const wsPath = "/eve/v1/realtime-speech/ws"; + +function clearControlEnv() { + delete process.env.EVE_REALTIME_CONTROL_URL; + delete process.env.VERCEL_BRANCH_URL; + delete process.env.VERCEL_URL; + delete process.env.VERCEL_PROJECT_PRODUCTION_URL; + delete process.env.VERCEL_AUTOMATION_BYPASS_SECRET; + delete process.env.VERCEL_DPBP; +} + +// The dev shell may export VERCEL_DPBP; clear it so "no bypass" cases are +// deterministic regardless of ambient env. +beforeEach(clearControlEnv); +afterEach(clearControlEnv); + +describe("resolveControlUrl", () => { + it("honors an explicit override URL", () => { + const url = resolveControlUrl({ + wsPath, + explicitUrl: "wss://tunnel.ngrok.app/eve/v1/realtime-speech/ws", + }); + expect(url).toBe("wss://tunnel.ngrok.app/eve/v1/realtime-speech/ws"); + }); + + it("derives a wss URL from the Vercel deployment host", () => { + process.env.VERCEL_BRANCH_URL = "eve-preview.vercel.app"; + const url = resolveControlUrl({ wsPath }); + expect(url).toBe("wss://eve-preview.vercel.app/eve/v1/realtime-speech/ws"); + }); + + it("appends the deploy-protection bypass secret as a query param", () => { + process.env.VERCEL_URL = "eve-preview.vercel.app"; + process.env.VERCEL_AUTOMATION_BYPASS_SECRET = "bypass123"; + const url = new URL(resolveControlUrl({ wsPath })); + expect(url.searchParams.get("x-vercel-protection-bypass")).toBe("bypass123"); + }); + + it("does not append deploy-protection bypass secrets to non-Vercel hosts", () => { + process.env.VERCEL_DPBP = "ambient-bypass"; + const url = new URL( + resolveControlUrl({ + wsPath, + explicitUrl: "wss://tunnel.ngrok.app/eve/v1/realtime-speech/ws", + }), + ); + expect(url.searchParams.get("x-vercel-protection-bypass")).toBe(null); + }); + + it("uses an explicit bypass secret for Vercel hosts", () => { + const url = new URL( + resolveControlUrl({ + wsPath, + explicitUrl: "wss://eve-preview.vercel.app/eve/v1/realtime-speech/ws", + bypassSecret: "explicit-bypass", + }), + ); + expect(url.searchParams.get("x-vercel-protection-bypass")).toBe("explicit-bypass"); + }); + + it("throws when no control URL or deployment host is configured", () => { + expect(() => resolveControlUrl({ wsPath })).toThrow(/EVE_REALTIME_CONTROL_URL/); + }); + + it("rejects public ws:// control URLs", () => { + expect(() => + resolveControlUrl({ + wsPath, + explicitUrl: "ws://app.example.com/eve/v1/realtime-speech/ws", + }), + ).toThrow(/wss:\/\//); + }); + + it("uses ws:// for localhost overrides", () => { + const url = resolveControlUrl({ + wsPath, + explicitUrl: "ws://localhost:3000/eve/v1/realtime-speech/ws", + }); + expect(url).toBe("ws://localhost:3000/eve/v1/realtime-speech/ws"); + }); +}); diff --git a/packages/eve/src/public/channels/vercel/control-url.ts b/packages/eve/src/public/channels/vercel/control-url.ts new file mode 100644 index 000000000..b59f2acf5 --- /dev/null +++ b/packages/eve/src/public/channels/vercel/control-url.ts @@ -0,0 +1,96 @@ +/** + * Builds the `control.url` Eve mints into the Gateway realtime token: the public + * `wss://` URL of Eve's own WebSocket control route that AI Gateway dials back. + * + * Resolution order: + * 1. `EVE_REALTIME_CONTROL_URL` (or the explicit override) — a full `wss://` + * (or `ws://localhost`) URL, used for tunneled local dev (ngrok/preview). + * 2. The deployment host from `VERCEL_BRANCH_URL` / `VERCEL_URL` / + * `VERCEL_PROJECT_PRODUCTION_URL`. + * + * AI Gateway dials this URL with only an `Authorization` header and does not + * follow redirects, so a Vercel Deployment Protection bypass cannot ride a + * header — it is appended to the URL as the `x-vercel-protection-bypass` query + * param for Vercel deployment hosts only (read from + * `VERCEL_AUTOMATION_BYPASS_SECRET`, falling back to `VERCEL_DPBP`). This is a + * temporary measure for protected preview testing. + */ +export interface ResolveControlUrlInput { + /** The `/ws` route path, e.g. `/eve/v1/realtime-speech/ws`. */ + readonly wsPath: string; + /** Explicit full WS URL override (defaults to `EVE_REALTIME_CONTROL_URL`). */ + readonly explicitUrl?: string; + /** Explicit deploy-protection bypass secret override. */ + readonly bypassSecret?: string; +} + +export function resolveControlUrl(input: ResolveControlUrlInput): string { + const base = resolveBaseUrl(input); + const bypass = readNonEmpty(input.bypassSecret) ?? readDeployBypassSecret(); + if (bypass !== undefined && isVercelHost(base.hostname)) { + base.searchParams.set("x-vercel-protection-bypass", bypass); + } + return base.toString(); +} + +function resolveBaseUrl(input: ResolveControlUrlInput): URL { + const explicit = + readNonEmpty(input.explicitUrl) ?? readNonEmpty(process.env.EVE_REALTIME_CONTROL_URL); + if (explicit !== undefined) { + // The override carries the full URL including path; honor it verbatim. + return validateControlUrl(new URL(explicit)); + } + + const host = resolveDeploymentHost(); + if (host === undefined) { + throw new Error( + "Eve realtime voice control could not resolve a public host. Set EVE_REALTIME_CONTROL_URL.", + ); + } + const scheme = isLocalHost(host) ? "ws" : "wss"; + return validateControlUrl(new URL(`${scheme}://${host}${input.wsPath}`)); +} + +function resolveDeploymentHost(): string | undefined { + const fromEnv = + readNonEmpty(process.env.VERCEL_BRANCH_URL) ?? + readNonEmpty(process.env.VERCEL_URL) ?? + readNonEmpty(process.env.VERCEL_PROJECT_PRODUCTION_URL); + if (fromEnv !== undefined) return stripScheme(fromEnv); + + return undefined; +} + +function readDeployBypassSecret(): string | undefined { + return ( + readNonEmpty(process.env.VERCEL_AUTOMATION_BYPASS_SECRET) ?? + readNonEmpty(process.env.VERCEL_DPBP) + ); +} + +function isLocalHost(host: string): boolean { + const hostname = host.split(":")[0] ?? host; + return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1"; +} + +function isVercelHost(hostname: string): boolean { + return hostname.endsWith(".vercel.app") || hostname.endsWith(".vercel.sh"); +} + +function validateControlUrl(url: URL): URL { + if (url.protocol === "wss:") return url; + if (url.protocol === "ws:" && isLocalHost(url.host)) return url; + throw new Error( + "Eve realtime voice control URL must use wss://, or ws://localhost for local dev.", + ); +} + +function stripScheme(value: string): string { + return value.replace(/^[a-z]+:\/\//iu, ""); +} + +function readNonEmpty(value: string | undefined | null): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} diff --git a/packages/eve/src/public/channels/vercel/speech.test.ts b/packages/eve/src/public/channels/vercel/speech.test.ts new file mode 100644 index 000000000..cc161e7b0 --- /dev/null +++ b/packages/eve/src/public/channels/vercel/speech.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it, vi } from "vitest"; + +import { isHttpRouteDefinition, isWebSocketRouteDefinition } from "#channel/routes.js"; +import type { Channel } from "#public/definitions/defineChannel.js"; +import { none } from "#public/channels/auth.js"; +import { createControlToken, verifyControlToken } from "#public/channels/vercel/control-token.js"; +import { vercelSpeechChannel } from "#public/channels/vercel/speech.js"; + +async function callRoute( + channel: Channel, + method: string, + path: string, + request: Request, +): Promise { + const route = channel.routes.find( + (candidate) => candidate.method === method && candidate.path === path, + ); + if (route === undefined || !isHttpRouteDefinition(route)) { + throw new Error(`Missing HTTP route ${method} ${path}`); + } + return route.handler(request, { + getSession: vi.fn() as any, + params: {}, + receive: vi.fn() as any, + requestIp: null, + send: vi.fn() as any, + waitUntil: vi.fn(), + }); +} + +describe("vercelSpeechChannel", () => { + it("mints a Gateway realtime token and returns the voice session id", async () => { + const getToken = vi.fn(async () => ({ + expiresAt: 1_700_000_060, + token: "vcst_test", + url: "wss://gateway.example/realtime-model?ai-model-id=openai%2Fgpt-realtime-2", + })); + const channel = vercelSpeechChannel({ + auth: none(), + basePath: "/voice", + createVoiceSessionId: () => "voice-session-1", + expiresAfterSeconds: 120, + getToken, + }); + + const response = await callRoute( + channel, + "POST", + "/voice/setup", + new Request("http://localhost/voice/setup"), + ); + const body = (await response.json()) as Record; + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(getToken).toHaveBeenCalledWith({ + expiresAfterSeconds: 120, + model: "openai/gpt-realtime-2", + }); + expect(body).toMatchObject({ + expiresAt: 1_700_000_060, + tools: [], + token: "vcst_test", + url: "wss://gateway.example/realtime-model?ai-model-id=openai%2Fgpt-realtime-2", + voiceSessionId: "voice-session-1", + }); + }); + + it("reuses a client-supplied voice session id", async () => { + const channel = vercelSpeechChannel({ + auth: none(), + basePath: "/voice", + getToken: async () => ({ token: "vcst_unused", url: "wss://gateway.example" }), + }); + + const response = await callRoute( + channel, + "POST", + "/voice/setup", + new Request("http://localhost/voice/setup?voiceSessionId=existing-session"), + ); + const body = (await response.json()) as Record; + + expect(body.voiceSessionId).toBe("existing-session"); + }); + + it("exposes only setup and health routes (no blocking /turn route)", () => { + const channel = vercelSpeechChannel({ + auth: none(), + basePath: "/voice", + getToken: async () => ({ token: "vcst_unused", url: "wss://gateway.example" }), + }); + + expect(channel.routes.map((route) => `${route.method} ${route.path}`).sort()).toEqual([ + "GET /voice/health", + "POST /voice/setup", + ]); + expect(channel.routes.some((route) => route.path.endsWith("/turn"))).toBe(false); + }); + + it("serves a health route", async () => { + const channel = vercelSpeechChannel({ + auth: none(), + basePath: "/voice", + model: "openai/gpt-realtime-2", + getToken: async () => ({ token: "vcst_unused", url: "wss://gateway.example" }), + }); + + const response = await callRoute( + channel, + "GET", + "/voice/health", + new Request("http://localhost/voice/health"), + ); + const body = (await response.json()) as Record; + + expect(body).toEqual({ + ok: true, + channel: "realtime-speech", + control: false, + model: "openai/gpt-realtime-2", + }); + }); + + it("mints control config and serves the control route in gateway-control mode", async () => { + process.env.EVE_REALTIME_CONTROL_SECRET = "ws-test-secret"; + const bypass = process.env.VERCEL_DPBP; + const bypass2 = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; + delete process.env.VERCEL_DPBP; + delete process.env.VERCEL_AUTOMATION_BYPASS_SECRET; + try { + const captured: Array> = []; + const getToken = vi.fn(async (options: Record) => { + captured.push(options); + return { token: "vcst_x", url: "wss://gateway.example" }; + }); + const channel = vercelSpeechChannel({ + auth: () => ({ + attributes: {}, + authenticator: "test", + principalId: "u1", + principalType: "user", + }), + basePath: "/voice", + control: { controlUrl: "wss://eve.example/voice/ws" }, + getToken, + }); + + await callRoute(channel, "POST", "/voice/setup", new Request("http://localhost/voice/setup")); + + const control = captured[0]!.control as { mode: string; token: string; url: string }; + expect(control.mode).toBe("eve"); + expect(control.url).toBe("wss://eve.example/voice/ws"); + const verified = await verifyControlToken(control.token, { secret: "ws-test-secret" }); + expect(verified).toMatchObject({ ok: true, voiceSessionId: expect.any(String) }); + + expect( + channel.routes.some((route) => route.method === "WEBSOCKET" && route.path === "/voice/ws"), + ).toBe(true); + } finally { + delete process.env.EVE_REALTIME_CONTROL_SECRET; + if (bypass !== undefined) process.env.VERCEL_DPBP = bypass; + if (bypass2 !== undefined) process.env.VERCEL_AUTOMATION_BYPASS_SECRET = bypass2; + } + }); + + it("rejects an unauthenticated control upgrade and accepts a valid token", async () => { + process.env.EVE_REALTIME_CONTROL_SECRET = "ws-test-secret"; + try { + const channel = vercelSpeechChannel({ + auth: none(), + basePath: "/voice", + control: true, + getToken: async () => ({ token: "vcst_x", url: "wss://gateway.example" }), + }); + const route = channel.routes.find( + (candidate) => candidate.method === "WEBSOCKET" && candidate.path === "/voice/ws", + ); + if (route === undefined || !isWebSocketRouteDefinition(route)) { + throw new Error("Missing control WS route"); + } + const hooks = await route.handler(new Request("http://localhost/voice/ws"), { + getSession: vi.fn() as any, + params: {}, + receive: vi.fn() as any, + requestIp: null, + send: vi.fn() as any, + waitUntil: vi.fn(), + }); + + const rejected = await hooks.upgrade!(new Request("http://localhost/voice/ws")); + expect(rejected).toBeInstanceOf(Response); + expect((rejected as Response).status).toBe(401); + + const token = await createControlToken({ + auth: { attributes: {}, authenticator: "t", principalId: "u", principalType: "user" }, + voiceSessionId: "v1", + ttlSeconds: 60, + secret: "ws-test-secret", + }); + const accepted = await hooks.upgrade!( + new Request("http://localhost/voice/ws", { headers: { authorization: `Bearer ${token}` } }), + ); + expect(accepted).not.toBeInstanceOf(Response); + } finally { + delete process.env.EVE_REALTIME_CONTROL_SECRET; + } + }); +}); diff --git a/packages/eve/src/public/channels/vercel/speech.ts b/packages/eve/src/public/channels/vercel/speech.ts new file mode 100644 index 000000000..5fe04d09f --- /dev/null +++ b/packages/eve/src/public/channels/vercel/speech.ts @@ -0,0 +1,323 @@ +import { gateway } from "ai"; + +import type { AuthFn } from "#public/channels/auth.js"; +import { routeAuth } from "#public/channels/auth.js"; +import type { SessionAuthContext } from "#channel/types.js"; +import { + defineChannel, + GET, + POST, + WS, + type Channel, + type RouteDefinition, +} from "#public/definitions/defineChannel.js"; +import { + createControlToken, + resolveControlSecret, + verifyControlToken, +} from "#public/channels/vercel/control-token.js"; +import { resolveControlUrl } from "#public/channels/vercel/control-url.js"; +import { + EVE_VOICE_CONTROL_PROTOCOL, + parseControlPacket, +} from "#public/channels/vercel/voice-control-protocol.js"; +import { + createInMemoryVoiceControlStateStore, + VoiceTurnCoordinator, + type VoiceControlStateStore, + type VoiceTurnCoordinatorOptions, +} from "#public/channels/vercel/voice-turn-coordinator.js"; + +const DEFAULT_BASE_PATH = "/eve/v1/realtime-speech"; +const DEFAULT_MODEL = "openai/gpt-realtime-2"; +const DEFAULT_CONTROL_TOKEN_TTL_SECONDS = 600; + +/** + * Gateway-owned control plane ("A-lite") configuration. When set, the setup + * route mints a `vcst_` token carrying an Eve control socket config, and the + * channel serves the `WS()` control route AI Gateway dials back. Pass `true` + * for defaults. + */ +export interface VercelSpeechControlInput { + /** HMAC secret for control tokens. Defaults to `EVE_REALTIME_CONTROL_SECRET`. */ + readonly secret?: string; + /** Allow deriving the control-token signing secret from `AI_GATEWAY_API_KEY`. Local/preview only. */ + readonly allowGatewayKeyFallback?: boolean; + /** Full `wss://` control URL override. Defaults to `EVE_REALTIME_CONTROL_URL` / deployment host. */ + readonly controlUrl?: string; + /** Vercel deploy-protection bypass secret override (for protected previews). */ + readonly bypassSecret?: string; + /** Control-token TTL (seconds). Default 600. */ + readonly tokenTtlSeconds?: number; + /** Durable context strings contributed on each control-driven turn. */ + readonly context?: readonly string[]; + /** Durable state for continuation/cursor recovery across control WS reconnects. */ + readonly stateStore?: VoiceControlStateStore; + /** Turn settle/debounce window (ms). */ + readonly settleMs?: number; +} + +/** + * Eve-owned mirror of the AI Gateway realtime client-secret result (`token`, + * `url`, `expiresAt`). Declared locally so eve's public channel surface does not + * re-export the AI SDK's experimental realtime types, which can change freely. + */ +export interface VercelRealtimeClientSecret { + readonly token: string; + readonly url: string; + readonly expiresAt?: number; +} + +/** + * Gateway realtime `control` config sealed into the minted token. Structurally + * mirrors `@ai-sdk/gateway`'s `GatewayRealtimeControlConfig`; defined locally so + * eve does not depend on the gateway type re-export. + */ +export interface VercelRealtimeControlConfig { + readonly mode: "eve"; + readonly token: string; + readonly url: string; +} + +export interface VercelSpeechGetTokenInput { + readonly expiresAfterSeconds?: number; + readonly model: string; + readonly control?: VercelRealtimeControlConfig; +} + +export interface VercelSpeechChannelInput { + /** Route auth used by the setup route. */ + readonly auth: AuthFn | readonly AuthFn[]; + /** AI Gateway realtime model id. */ + readonly model?: string; + /** Base path for the setup, health, and control routes. */ + readonly basePath?: string; + /** Client-secret TTL forwarded to AI Gateway. */ + readonly expiresAfterSeconds?: number; + /** + * Enable the Gateway-owned control plane (A-lite). When set, `/setup` mints a + * token with control config and the `{basePath}/ws` control route is served. + */ + readonly control?: VercelSpeechControlInput | boolean; + /** Test/advanced injection point for token minting. Defaults to AI Gateway. */ + readonly getToken?: (input: VercelSpeechGetTokenInput) => Promise; + /** Test/advanced injection point for creating long-lived voice session ids. */ + readonly createVoiceSessionId?: () => string; +} + +export interface VercelSpeechSetupResponse extends VercelRealtimeClientSecret { + /** Whether this token carries Gateway-owned Eve control config. */ + readonly control: boolean; + /** No model-visible tools are exposed to the realtime speech adapter. */ + readonly tools: readonly []; + readonly voiceSessionId: string; +} + +/** + * Builds an Eve channel for long-lived realtime speech sessions backed by Vercel + * AI Gateway realtime audio. + * + * Default (client-driven) mode: the browser keeps an AI Gateway realtime socket + * open using the setup route's short-lived `vcst_` token, and finalized + * transcripts run as ordinary durable turns through `/eve/v1/session`. + * + * Gateway-control mode (A-lite, opt in via `control`): `/setup` additionally + * mints control config into the token so AI Gateway dials Eve's `{basePath}/ws` + * route per session; Eve then owns turn coordination and streams reply text back + * for Gateway to inject into provider TTS. Either way the realtime model is only + * the ears and mouth and Eve stays the durable assistant of record. + */ +export function vercelSpeechChannel(input: VercelSpeechChannelInput): Channel { + const basePath = normalizeBasePath(input.basePath ?? DEFAULT_BASE_PATH); + const model = input.model ?? DEFAULT_MODEL; + const getToken = + input.getToken ?? + ((options: VercelSpeechGetTokenInput) => gateway.experimental_realtime.getToken(options)); + const createVoiceSessionId = input.createVoiceSessionId ?? (() => crypto.randomUUID()); + const controlOptions = normalizeControlInput(input.control); + const wsPath = `${basePath}/ws`; + + const routes: RouteDefinition[] = [ + POST(`${basePath}/setup`, async (req) => { + const authResult = await routeAuth(req, input.auth); + if (authResult instanceof Response) return authResult; + + const url = new URL(req.url); + const voiceSessionId = + readOptionalString(url.searchParams.get("voiceSessionId")) ?? createVoiceSessionId(); + + let control: VercelRealtimeControlConfig | undefined; + if (controlOptions !== undefined) { + const secret = resolveControlSecret(controlOptions.secret, { + allowGatewayKeyFallback: controlOptions.allowGatewayKeyFallback, + }); + const token = await createControlToken({ + auth: authResult, + voiceSessionId, + ttlSeconds: controlOptions.tokenTtlSeconds ?? DEFAULT_CONTROL_TOKEN_TTL_SECONDS, + secret, + }); + const controlUrlInput: { + wsPath: string; + explicitUrl?: string; + bypassSecret?: string; + } = { wsPath }; + if (controlOptions.controlUrl !== undefined) { + controlUrlInput.explicitUrl = controlOptions.controlUrl; + } + if (controlOptions.bypassSecret !== undefined) { + controlUrlInput.bypassSecret = controlOptions.bypassSecret; + } + control = { mode: "eve", token, url: resolveControlUrl(controlUrlInput) }; + } + + const getTokenInput: { + model: string; + expiresAfterSeconds?: number; + control?: VercelRealtimeControlConfig; + } = { model }; + if (input.expiresAfterSeconds !== undefined) { + getTokenInput.expiresAfterSeconds = input.expiresAfterSeconds; + } + if (control !== undefined) getTokenInput.control = control; + const token = await getToken(getTokenInput); + + return jsonNoStore({ + ...token, + control: control !== undefined, + tools: [], + voiceSessionId, + } satisfies VercelSpeechSetupResponse); + }), + + GET(`${basePath}/health`, async () => + jsonNoStore({ + ok: true, + channel: "realtime-speech", + control: controlOptions !== undefined, + model, + }), + ), + ]; + + if (controlOptions !== undefined) { + routes.push(createControlRoute({ wsPath, controlOptions })); + } + + return defineChannel({ + kindHint: "realtime-speech", + routes, + }); +} + +function createControlRoute(input: { + readonly wsPath: string; + readonly controlOptions: VercelSpeechControlInput; +}): RouteDefinition { + // Per-connection coordinators, keyed by peer id. eve invokes the WS route + // handler per hook (upgrade/open/message run in separate closures), so + // connection state cannot live in the handler closure — it is keyed here on + // the stable `peer.id`, and the principal is recovered from `peer.request`. + const connections = new Map(); + const stateStore = input.controlOptions.stateStore ?? createInMemoryVoiceControlStateStore(); + + async function verifyPeer( + request: Request, + ): Promise<{ auth: SessionAuthContext; voiceSessionId: string } | undefined> { + let secret: string; + try { + secret = resolveControlSecret(input.controlOptions.secret, { + allowGatewayKeyFallback: input.controlOptions.allowGatewayKeyFallback, + }); + } catch { + return undefined; + } + const result = await verifyControlToken(readBearerToken(request.headers.get("authorization")), { + secret, + }); + return result.ok ? { auth: result.auth, voiceSessionId: result.voiceSessionId } : undefined; + } + + return WS(input.wsPath, (_req, args) => ({ + async upgrade(request) { + // Reject bad tokens at the handshake for a clean 401. + const verified = await verifyPeer(request); + if (verified === undefined) return new Response("Unauthorized", { status: 401 }); + return { headers: { "sec-websocket-protocol": EVE_VOICE_CONTROL_PROTOCOL } }; + }, + async open(peer) { + const verified = await verifyPeer(peer.request); + if (verified === undefined) { + peer.close(1011, "unverified"); + return; + } + const coordinatorOptions: { + -readonly [K in keyof VoiceTurnCoordinatorOptions]: VoiceTurnCoordinatorOptions[K]; + } = { + auth: verified.auth, + voiceSessionId: verified.voiceSessionId, + send: args.send, + sendRaw: (packet) => peer.send(packet), + stateStore, + closeSocket: (code, reason) => peer.close(code, reason), + }; + if (input.controlOptions.context !== undefined) { + coordinatorOptions.context = input.controlOptions.context; + } + if (input.controlOptions.settleMs !== undefined) { + coordinatorOptions.settleMs = input.controlOptions.settleMs; + } + const coordinator = new VoiceTurnCoordinator(coordinatorOptions); + connections.set(peer.id, coordinator); + coordinator.start(); + }, + message(peer, message) { + const event = parseControlPacket(message.text()); + if (event !== null) connections.get(peer.id)?.handle(event); + }, + close(peer) { + connections.get(peer.id)?.dispose(); + connections.delete(peer.id); + }, + error(peer) { + connections.get(peer.id)?.dispose(); + connections.delete(peer.id); + }, + })); +} + +function normalizeControlInput( + control: VercelSpeechChannelInput["control"], +): VercelSpeechControlInput | undefined { + if (control === undefined || control === false) return undefined; + if (control === true) return {}; + return control; +} + +function readBearerToken(header: string | null): string | undefined { + if (header === null) return undefined; + const match = /^Bearer\s+(.+)$/iu.exec(header.trim()); + return match?.[1]; +} + +function readOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeBasePath(path: string): string { + const trimmed = path.trim().replace(/\/+$/u, ""); + if (!trimmed.startsWith("/") || trimmed.length === 0) { + throw new Error("vercelSpeechChannel basePath must start with `/`."); + } + return trimmed; +} + +function jsonNoStore(body: unknown): Response { + return Response.json(body, { + headers: { + "cache-control": "no-store", + }, + }); +} diff --git a/packages/eve/src/public/channels/vercel/voice-control-protocol.ts b/packages/eve/src/public/channels/vercel/voice-control-protocol.ts new file mode 100644 index 000000000..9d7fd6535 --- /dev/null +++ b/packages/eve/src/public/channels/vercel/voice-control-protocol.ts @@ -0,0 +1,17 @@ +/** + * Eve's vocabulary for the AI Gateway speech-engine control protocol. The wire + * contract (envelope, events, capabilities, codec) is defined once in + * `@ai-sdk/gateway` and shared with the Gateway so the two can't drift; this + * module just re-exports it under Eve-local names. Eve is the controller: it + * receives engine→controller events and sends controller→engine events. + */ +export { + DEFAULT_SPEECH_ENGINE_CAPABILITIES as DEFAULT_CONTROL_CAPABILITIES, + encodeSpeechEngineEvent as encodeControlPacket, + GATEWAY_SPEECH_ENGINE_SUBPROTOCOL as EVE_VOICE_CONTROL_PROTOCOL, + parseSpeechEngineServerEvent as parseControlPacket, + type SpeechEngineCapabilities as RealtimeControlCapabilities, + type SpeechEngineClientEvent as EveToGatewayEvent, + type SpeechEngineDescriptor as RealtimeControlEngine, + type SpeechEngineServerEvent as GatewayToEveEvent, +} from "@ai-sdk/gateway"; diff --git a/packages/eve/src/public/channels/vercel/voice-turn-coordinator.test.ts b/packages/eve/src/public/channels/vercel/voice-turn-coordinator.test.ts new file mode 100644 index 000000000..6bdfd398a --- /dev/null +++ b/packages/eve/src/public/channels/vercel/voice-turn-coordinator.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { SendFn } from "#channel/routes.js"; +import type { SessionAuthContext } from "#channel/types.js"; +import { + DEFAULT_CONTROL_CAPABILITIES, + type GatewayToEveEvent, + type RealtimeControlCapabilities, +} from "#public/channels/vercel/voice-control-protocol.js"; +import { + createInMemoryVoiceControlStateStore, + VoiceTurnCoordinator, + type VoiceControlStateStore, +} from "#public/channels/vercel/voice-turn-coordinator.js"; + +function sessionOpened( + overrides: Partial, +): Extract { + return { + type: "session.opened", + data: { + sessionId: "s1", + engine: { + provider: "openai", + model: "openai/gpt-realtime-2", + protocol: "ai-sdk", + capabilities: { ...DEFAULT_CONTROL_CAPABILITIES, ...overrides }, + }, + }, + }; +} + +const auth: SessionAuthContext = { + attributes: {}, + authenticator: "test", + principalId: "user-1", + principalType: "user", +}; + +function closedStream(events: readonly unknown[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const event of events) controller.enqueue(event); + controller.close(); + }, + }); +} + +function sendReturning(events: readonly unknown[], id = "session-1") { + const impl: SendFn = async () => ({ + id, + continuationToken: "voice:ct", + getEventStream: async () => closedStream(events), + }); + return vi.fn(impl); +} + +function harness( + send: SendFn, + settleMs = 5, + options: { stateStore?: VoiceControlStateStore } = {}, +) { + const packets: Array<{ type: string; data: Record }> = []; + const coordinator = new VoiceTurnCoordinator({ + auth, + voiceSessionId: "voice-1", + send, + sendRaw: (packet) => packets.push(JSON.parse(packet)), + closeSocket: () => undefined, + ...options, + settleMs, + }); + return { coordinator, packets, types: () => packets.map((p) => p.type) }; +} + +const reply = (message: string, stepIndex = 0, finishReason = "stop") => ({ + type: "message.completed", + data: { finishReason, message, sequence: 1, stepIndex, turnId: "t1" }, +}); +const waiting = () => ({ type: "session.waiting", data: { wait: "next-user-message" } }); +const failed = () => ({ type: "session.failed", data: { code: "boom", message: "boom" } }); + +describe("VoiceTurnCoordinator", () => { + it("emits session.ready on start", async () => { + const { coordinator, types } = harness(sendReturning([])); + coordinator.start(); + await vi.waitFor(() => expect(types()).toEqual(["session.ready"])); + }); + + it("runs a durable turn and streams response.delta + response.done", async () => { + const send = sendReturning([reply("Hello there"), waiting()]); + const { coordinator, packets } = harness(send); + coordinator.start(); + + coordinator.handle({ type: "input.transcript.final", data: { text: "hi", itemId: "i1" } }); + + await vi.waitFor(() => expect(packets.some((p) => p.type === "response.done")).toBe(true)); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ message: "hi" }), + expect.objectContaining({ auth, mode: "conversation" }), + ); + const delta = packets.find((p) => p.type === "response.delta"); + expect(delta?.data).toEqual({ text: "Hello there", turnId: "turn_1" }); + // turn.started / response.delta / response.done share the turn id so the + // Gateway can correlate frames and drop a superseded turn's frames by id. + expect(packets.find((p) => p.type === "turn.started")?.data.turnId).toBe("turn_1"); + expect(packets.find((p) => p.type === "response.done")?.data.turnId).toBe("turn_1"); + }); + + it("does not speak intermediate tool-call text", async () => { + const send = sendReturning([ + reply("Let me check that", 0, "tool-calls"), + reply("The weather is mild", 1, "stop"), + waiting(), + ]); + const { coordinator, packets } = harness(send); + coordinator.start(); + coordinator.handle({ + type: "input.transcript.final", + data: { text: "weather?", itemId: "i1" }, + }); + + await vi.waitFor(() => expect(packets.some((p) => p.type === "response.done")).toBe(true)); + const deltas = packets.filter((p) => p.type === "response.delta").map((p) => p.data.text); + expect(deltas).toEqual(["The weather is mild"]); + }); + + it("ignores backchannel acknowledgements", async () => { + const send = sendReturning([reply("ok"), waiting()]); + const { coordinator } = harness(send); + coordinator.start(); + coordinator.handle({ type: "input.transcript.final", data: { text: "mm-hmm", itemId: "i1" } }); + + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(send).not.toHaveBeenCalled(); + }); + + it("de-duplicates a repeated transcript itemId", async () => { + const send = sendReturning([reply("Hi"), waiting()]); + const { coordinator, packets } = harness(send); + coordinator.start(); + coordinator.handle({ type: "input.transcript.final", data: { text: "hi", itemId: "dup" } }); + coordinator.handle({ type: "input.transcript.final", data: { text: "hi", itemId: "dup" } }); + + await vi.waitFor(() => expect(packets.some((p) => p.type === "response.done")).toBe(true)); + expect(send).toHaveBeenCalledTimes(1); + }); + + it("de-duplicates immediate repeated transcript text when itemId is missing", async () => { + const send = sendReturning([reply("Hi"), waiting()]); + const { coordinator, packets } = harness(send); + coordinator.start(); + coordinator.handle({ type: "input.transcript.final", data: { text: "hi" } }); + coordinator.handle({ type: "input.transcript.final", data: { text: "hi" } }); + + await vi.waitFor(() => expect(packets.some((p) => p.type === "response.done")).toBe(true)); + expect(send).toHaveBeenCalledTimes(1); + }); + + it("persists continuation and stream cursor across control socket reconnects", async () => { + const stateStore = createInMemoryVoiceControlStateStore(); + const streamStarts: Array = []; + const send = vi.fn(async () => ({ + id: "session-1", + continuationToken: "voice:stable", + getEventStream: async (options) => { + streamStarts.push(options?.startIndex); + return closedStream([reply("Hi"), waiting()]); + }, + })); + + const first = harness(send, 5, { stateStore }); + first.coordinator.start(); + first.coordinator.handle({ type: "input.transcript.final", data: { text: "first" } }); + await vi.waitFor(() => expect(first.types()).toContain("response.done")); + + const second = harness(send, 5, { stateStore }); + second.coordinator.start(); + second.coordinator.handle({ type: "input.transcript.final", data: { text: "second" } }); + await vi.waitFor(() => expect(second.types()).toContain("response.done")); + + expect(streamStarts).toEqual([0, 2]); + expect(send.mock.calls[0]?.[1].continuationToken).toBe( + send.mock.calls[1]?.[1].continuationToken, + ); + }); + + it("emits error instead of response.done when the durable turn fails", async () => { + const send = sendReturning([failed()]); + const { coordinator, types } = harness(send); + coordinator.start(); + coordinator.handle({ type: "input.transcript.final", data: { text: "hi", itemId: "i1" } }); + + await vi.waitFor(() => expect(types()).toContain("error")); + expect(types()).not.toContain("response.done"); + }); + + it("clears pending transcript text on barge-in before the turn settles", async () => { + const send = sendReturning([reply("Hi"), waiting()]); + const { coordinator, packets } = harness(send); + coordinator.start(); + coordinator.handle({ type: "input.transcript.final", data: { text: "first", itemId: "i1" } }); + coordinator.handle({ type: "input.interrupted", data: {} }); + coordinator.handle({ type: "input.transcript.final", data: { text: "second", itemId: "i2" } }); + + await vi.waitFor(() => expect(packets.some((p) => p.type === "response.done")).toBe(true)); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ message: "second" }), + expect.anything(), + ); + expect(send).toHaveBeenCalledTimes(1); + }); + + it("cancels an in-flight response on barge-in", async () => { + let controller: ReadableStreamDefaultController | undefined; + const impl: SendFn = async () => ({ + id: "session-1", + continuationToken: "voice:ct", + getEventStream: async () => + new ReadableStream({ + start(c) { + controller = c; + }, + }), + }); + const send = vi.fn(impl); + const { coordinator, packets, types } = harness(send); + coordinator.start(); + coordinator.handle({ + type: "input.transcript.final", + data: { text: "tell me a story", itemId: "i1" }, + }); + + // Wait for the turn to start and emit a delta (response in flight). + await vi.waitFor(() => expect(controller).toBeDefined()); + controller!.enqueue(reply("Once upon a")); + await vi.waitFor(() => expect(packets.some((p) => p.type === "response.delta")).toBe(true)); + + coordinator.handle({ type: "input.interrupted", data: {} }); + + expect(types()).toContain("response.cancel"); + expect(types()).not.toContain("response.done"); + }); + + it("runs the turn but skips the spoken readout when output.audio is false", async () => { + const send = sendReturning([reply("Hello there"), waiting()]); + const { coordinator, packets } = harness(send); + coordinator.start(); + coordinator.handle(sessionOpened({ "output.audio": false })); + coordinator.handle({ type: "input.transcript.final", data: { text: "hi", itemId: "i1" } }); + + await vi.waitFor(() => expect(send).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(packets.some((p) => p.type === "response.delta")).toBe(false); + expect(packets.some((p) => p.type === "response.done")).toBe(true); + }); + + it("does not emit response.cancel on barge-in when output.cancel is false", async () => { + let controller: ReadableStreamDefaultController | undefined; + const impl: SendFn = async () => ({ + id: "session-1", + continuationToken: "voice:ct", + getEventStream: async () => + new ReadableStream({ + start(c) { + controller = c; + }, + }), + }); + const send = vi.fn(impl); + const { coordinator, packets, types } = harness(send); + coordinator.start(); + coordinator.handle(sessionOpened({ "output.cancel": false })); + coordinator.handle({ + type: "input.transcript.final", + data: { text: "tell me a story", itemId: "i1" }, + }); + + await vi.waitFor(() => expect(controller).toBeDefined()); + controller!.enqueue(reply("Once upon a")); + await vi.waitFor(() => expect(packets.some((p) => p.type === "response.delta")).toBe(true)); + + coordinator.handle({ type: "input.interrupted", data: {} }); + + expect(types()).not.toContain("response.cancel"); + }); +}); diff --git a/packages/eve/src/public/channels/vercel/voice-turn-coordinator.ts b/packages/eve/src/public/channels/vercel/voice-turn-coordinator.ts new file mode 100644 index 000000000..f86ce1b06 --- /dev/null +++ b/packages/eve/src/public/channels/vercel/voice-turn-coordinator.ts @@ -0,0 +1,393 @@ +import type { SendFn } from "#channel/routes.js"; +import type { Session } from "#channel/session.js"; +import type { SessionAuthContext } from "#channel/types.js"; +import { isCurrentTurnBoundaryEvent, isTurnFailureEvent } from "#protocol/message.js"; +import { + DEFAULT_CONTROL_CAPABILITIES, + encodeControlPacket, + type EveToGatewayEvent, + type GatewayToEveEvent, + type RealtimeControlCapabilities, +} from "#public/channels/vercel/voice-control-protocol.js"; + +/** Settle delay before a finalized transcript starts a turn; coalesces rapid finals. */ +const DEFAULT_SETTLE_MS = 220; +/** Bounds the dedupe set so a long session does not grow it without limit. */ +const MAX_TRACKED_ITEMS = 256; +/** Suppresses immediate duplicate transcript finals when Gateway omits item ids. */ +const TEXT_DEDUPE_MS = 10_000; + +/** Short acknowledgements that should not trigger a durable Eve turn. */ +const BACKCHANNELS = new Set([ + "ok", + "okay", + "yeah", + "yep", + "yup", + "uh huh", + "uh-huh", + "mhm", + "mm", + "mm-hmm", + "mmhmm", + "right", + "sure", + "got it", + "cool", + "nice", + "hmm", + "huh", + "okay cool", +]); + +export interface VoiceTurnCoordinatorOptions { + readonly auth: SessionAuthContext; + readonly voiceSessionId: string; + /** Channel `send`, used to run durable Eve turns. */ + readonly send: SendFn; + /** Sends a wire packet string to AI Gateway (the open WS peer). */ + readonly sendRaw: (packet: string) => void; + /** Closes the control socket with a code/reason (fail-closed). */ + readonly closeSocket: (code: number, reason: string) => void; + /** Optional durable context strings contributed on each turn. */ + readonly context?: readonly string[]; + /** Stores durable voice continuation/cursor state across control WS reconnects. */ + readonly stateStore?: VoiceControlStateStore; + /** Settle delay override (ms). */ + readonly settleMs?: number; +} + +export interface VoiceControlSessionState { + readonly continuationToken: string; + readonly sessionId?: string; + readonly streamIndex: number; +} + +export interface VoiceControlStateStore { + get( + key: string, + ): Promise | VoiceControlSessionState | undefined; + set(key: string, state: VoiceControlSessionState): Promise | void; +} + +export function createInMemoryVoiceControlStateStore(): VoiceControlStateStore { + const states = new Map(); + return { + get(key) { + return states.get(key); + }, + set(key, state) { + states.set(key, { ...state }); + }, + }; +} + +/** + * Drives durable Eve turns from the Gateway-owned realtime voice control socket. + * + * It receives finalized transcripts (and lifecycle/barge-in signals) from AI + * Gateway, debounces and de-duplicates them, runs one durable Eve turn per + * settled utterance via the channel `send`, and streams non-tool-call reply text + * back as `response.delta` / `response.done`. A user barge-in aborts the + * in-flight turn's relay and emits `response.cancel`. + * + * It degrades gracefully against the per-session capability hints the Gateway + * advertises in `session.opened.data.engine`: with `output.audio: false` it + * still runs the durable turn but skips the spoken readout; with + * `output.cancel: false` it aborts the local relay on barge-in without emitting + * `response.cancel`; it only consumes final transcripts; and it only reacts to + * an actual `input.interrupted` / `input.speech.started`, so it never promises + * barge-in the provider cannot honor. + */ +export class VoiceTurnCoordinator { + readonly #options: VoiceTurnCoordinatorOptions; + readonly #settleMs: number; + readonly #stateKey: string; + readonly #stateReady: Promise; + readonly #processedItemIds = new Set(); + readonly #processedTextFingerprints = new Map(); + + #seq = 0; + #turnSeq = 0; + #disposed = false; + #continuationToken: string; + #lastSessionId: string | undefined; + #streamIndex = 0; + + #pendingText = ""; + #settleTimer: ReturnType | undefined; + #queue: Promise = Promise.resolve(); + #activeTurn: + | { readonly abort: AbortController; cancelStream: () => void; readonly turnId: string } + | undefined; + #responseInFlight = false; + #capabilities: RealtimeControlCapabilities = DEFAULT_CONTROL_CAPABILITIES; + + constructor(options: VoiceTurnCoordinatorOptions) { + this.#options = options; + this.#settleMs = options.settleMs ?? DEFAULT_SETTLE_MS; + this.#stateKey = createStateKey(options.auth, options.voiceSessionId); + this.#continuationToken = createStableContinuationToken(options.auth, options.voiceSessionId); + this.#stateReady = this.#hydrateState(); + } + + /** Signals readiness so Gateway clears its ready timeout. */ + start(): void { + void this.#stateReady.then( + () => this.#emit({ type: "session.ready" }), + () => this.#fail("state_load_failed"), + ); + } + + /** Routes one inbound Gateway→Eve control event. */ + handle(event: GatewayToEveEvent): void { + if (this.#disposed) return; + switch (event.type) { + case "session.opened": + if (event.data.engine !== undefined) this.#capabilities = event.data.engine.capabilities; + return; + case "input.transcript.final": + this.#onTranscriptFinal(event.data.text, event.data.itemId); + return; + case "input.speech.started": + case "input.interrupted": + this.#bargeIn(); + return; + case "session.closed": + this.dispose(); + return; + case "error": + this.dispose(); + return; + // input.speech.stopped / session.stats — no action. + default: + return; + } + } + + /** Tears down timers and aborts any in-flight turn. */ + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#clearSettle(); + this.#activeTurn?.abort.abort(); + this.#activeTurn?.cancelStream(); + this.#activeTurn = undefined; + } + + #onTranscriptFinal(rawText: string, itemId?: string): void { + const text = rawText.trim(); + if (text.length === 0) return; + if (itemId !== undefined) { + if (this.#processedItemIds.has(itemId)) return; + this.#processedItemIds.add(itemId); + if (this.#processedItemIds.size > MAX_TRACKED_ITEMS) { + const oldest = this.#processedItemIds.values().next().value; + if (oldest !== undefined) this.#processedItemIds.delete(oldest); + } + } + if (isBackchannel(text)) return; + if (itemId === undefined && this.#isDuplicateText(text)) return; + + this.#pendingText = this.#pendingText.length > 0 ? `${this.#pendingText} ${text}` : text; + this.#clearSettle(); + this.#settleTimer = setTimeout(() => this.#flushPending(), this.#settleMs); + } + + #flushPending(): void { + this.#settleTimer = undefined; + const message = this.#pendingText; + this.#pendingText = ""; + if (message.length === 0 || this.#disposed) return; + this.#queue = this.#queue.catch(() => undefined).then(() => this.#runTurn(message)); + } + + #bargeIn(): void { + this.#clearSettle(); + this.#pendingText = ""; + const cancelledTurnId = this.#activeTurn?.turnId; + const hadResponse = this.#responseInFlight || this.#activeTurn !== undefined; + this.#activeTurn?.abort.abort(); + if (hadResponse) { + // Skip the cancel frame when the engine can't act on it; the local relay + // is already aborted above either way. The durable stream is still drained + // to its boundary so the next turn cannot race the same continuation. + if (this.#capabilities["output.cancel"] && cancelledTurnId !== undefined) { + this.#emit({ type: "response.cancel", data: { turnId: cancelledTurnId } }); + } + this.#responseInFlight = false; + } + } + + async #runTurn(message: string): Promise { + if (this.#disposed) return; + await this.#stateReady; + const abort = new AbortController(); + const turnId = `turn_${(this.#turnSeq += 1)}`; + const turn = { abort, cancelStream: () => undefined as void, turnId }; + this.#activeTurn = turn; + let session: Session | undefined; + let consumed = 0; + let failed = false; + + try { + this.#emit({ type: "turn.started", data: { turnId } }); + const payload: { message: string; context?: readonly string[] } = { message }; + if (this.#options.context !== undefined) payload.context = this.#options.context; + session = await this.#options.send(payload, { + auth: this.#options.auth, + continuationToken: this.#continuationToken, + mode: "conversation", + }); + + const startIndex = this.#lastSessionId === session.id ? this.#streamIndex : 0; + const stream = await session.getEventStream({ startIndex }); + const reader = stream.getReader(); + turn.cancelStream = () => { + void reader.cancel().catch(() => undefined); + }; + + const partials = new Map(); + try { + while (!this.#disposed) { + const { done, value } = await reader.read(); + if (done) break; + consumed += 1; + const event = value; + if (isTurnFailureEvent(event)) { + failed = true; + break; + } + if (event.type === "message.appended") { + partials.set( + event.data.stepIndex, + (partials.get(event.data.stepIndex) ?? "") + event.data.messageDelta, + ); + } else if (event.type === "message.completed") { + if (event.data.finishReason === "tool-calls") { + partials.delete(event.data.stepIndex); + continue; + } + const text = (partials.get(event.data.stepIndex) || event.data.message || "").trim(); + partials.delete(event.data.stepIndex); + // The durable turn always runs; only stream the spoken readout when + // the engine can actually speak it (`output.audio`). + if (text.length > 0 && !abort.signal.aborted && this.#capabilities["output.audio"]) { + this.#emit({ type: "response.delta", data: { text, turnId } }); + this.#responseInFlight = true; + } + } else if (isCurrentTurnBoundaryEvent(event)) { + break; + } + } + } finally { + try { + await reader.cancel(); + } catch { + // Best effort. + } + } + + await this.#persistState(session, startIndex + consumed); + + if (failed) { + this.#responseInFlight = false; + if (!abort.signal.aborted && !this.#disposed) { + this.#emit({ type: "error", data: { message: "turn_failed" } }); + } + return; + } + + if (!abort.signal.aborted && !this.#disposed) { + this.#emit({ type: "response.done", data: { turnId } }); + this.#responseInFlight = false; + } + } catch { + this.#responseInFlight = false; + if (!abort.signal.aborted && !this.#disposed) { + this.#emit({ type: "error", data: { message: "turn_failed" } }); + } + } finally { + if (this.#activeTurn === turn) this.#activeTurn = undefined; + } + } + + async #hydrateState(): Promise { + const state = await this.#options.stateStore?.get(this.#stateKey); + if (state === undefined) return; + if (state.continuationToken.length > 0) this.#continuationToken = state.continuationToken; + this.#lastSessionId = state.sessionId; + this.#streamIndex = Math.max(0, Math.floor(state.streamIndex)); + } + + async #persistState(session: Session, streamIndex: number): Promise { + this.#lastSessionId = session.id; + this.#streamIndex = streamIndex; + await this.#options.stateStore?.set(this.#stateKey, { + continuationToken: this.#continuationToken, + sessionId: this.#lastSessionId, + streamIndex: this.#streamIndex, + }); + } + + #isDuplicateText(text: string): boolean { + const now = Date.now(); + for (const [fingerprint, seenAt] of this.#processedTextFingerprints) { + if (now - seenAt > TEXT_DEDUPE_MS) this.#processedTextFingerprints.delete(fingerprint); + } + const fingerprint = textFingerprint(text); + if (fingerprint.length === 0) return false; + if (this.#processedTextFingerprints.has(fingerprint)) return true; + this.#processedTextFingerprints.set(fingerprint, now); + if (this.#processedTextFingerprints.size > MAX_TRACKED_ITEMS) { + const oldest = this.#processedTextFingerprints.keys().next().value; + if (oldest !== undefined) this.#processedTextFingerprints.delete(oldest); + } + return false; + } + + #fail(message: string): void { + if (!this.#disposed) this.#emit({ type: "error", data: { message } }); + this.#options.closeSocket(1011, message); + this.dispose(); + } + + #emit(event: EveToGatewayEvent): void { + if (this.#disposed && event.type !== "error") return; + this.#seq += 1; + this.#options.sendRaw(encodeControlPacket(this.#seq, event)); + } + + #clearSettle(): void { + if (this.#settleTimer !== undefined) { + clearTimeout(this.#settleTimer); + this.#settleTimer = undefined; + } + } +} + +function isBackchannel(text: string): boolean { + const normalized = text + .toLowerCase() + .replace(/[.!?,]+$/u, "") + .trim(); + return BACKCHANNELS.has(normalized); +} + +function createStateKey(auth: SessionAuthContext, voiceSessionId: string): string { + return ["voice-control", auth.authenticator, auth.principalType, auth.principalId, voiceSessionId] + .map(encodeStateKeyPart) + .join(":"); +} + +function createStableContinuationToken(auth: SessionAuthContext, voiceSessionId: string): string { + return createStateKey(auth, voiceSessionId); +} + +function encodeStateKeyPart(value: string): string { + return encodeURIComponent(value); +} + +function textFingerprint(text: string): string { + return text.toLowerCase().replace(/\s+/gu, " ").trim(); +} diff --git a/packages/eve/src/react/voice.test.ts b/packages/eve/src/react/voice.test.ts new file mode 100644 index 000000000..fdcd739c9 --- /dev/null +++ b/packages/eve/src/react/voice.test.ts @@ -0,0 +1,526 @@ +import { createElement } from "react"; +import { act, create } from "react-test-renderer"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const realtimeOptions: any[] = []; + +const realtimeState = { + cancelResponse: vi.fn(), + connect: vi.fn(async () => undefined), + disconnect: vi.fn(), + events: [], + isCapturing: false, + isPlaying: false, + messages: [], + requestResponse: vi.fn(), + sendEvent: vi.fn(), + startAudioCapture: vi.fn(), + status: "disconnected", + stopAudioCapture: vi.fn(), + stopPlayback: vi.fn(), +}; + +vi.mock("@ai-sdk/react", () => ({ + experimental_useRealtime: (options: unknown) => { + realtimeOptions.push(options); + return realtimeState; + }, +})); + +vi.mock("ai", () => ({ + __esModule: true, +})); + +afterEach(() => { + realtimeOptions.length = 0; + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +const SESSION_ID_HEADER = "x-eve-session-id"; + +function completedMessageEvent(message: string) { + return { + type: "message.completed", + data: { finishReason: "stop", message, sequence: 1, stepIndex: 0, turnId: "turn-1" }, + }; +} + +function ndjsonResponse(events: readonly unknown[]): Response { + const body = events.map((event) => JSON.stringify(event)).join("\n") + "\n"; + return new Response(body, { + status: 200, + headers: { "content-type": "application/x-ndjson" }, + }); +} + +/** + * Mocks the durable session API the voice hook now drives: a create/continue + * POST that acknowledges immediately, followed by an NDJSON event stream. + * Each entry in `turns` supplies the events for one turn, in order. + */ +function sessionFetchMock(turns: ReadonlyArray<{ sessionId: string; events: readonly unknown[] }>) { + let streamedTurns = 0; + return vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + const method = (init?.method ?? "GET").toUpperCase(); + const turn = turns[Math.min(streamedTurns, turns.length - 1)]!; + + if (method === "POST" && /\/eve\/v1\/session(\/[^/]+)?$/.test(url)) { + return Response.json( + { ok: true, sessionId: turn.sessionId, continuationToken: "eve:token" }, + { status: 202, headers: { [SESSION_ID_HEADER]: turn.sessionId } }, + ); + } + if (method === "GET" && /\/stream(\?|$)/.test(url)) { + const response = ndjsonResponse(turn.events); + streamedTurns += 1; + return response; + } + throw new Error(`Unexpected fetch: ${method} ${url}`); + }); +} + +function postCalls(fetch: ReturnType): unknown[][] { + return fetch.mock.calls.filter( + (call) => ((call[1] as RequestInit | undefined)?.method ?? "GET").toUpperCase() === "POST", + ); +} + +function streamCalls(fetch: ReturnType): unknown[][] { + return fetch.mock.calls.filter((call) => /\/stream(\?|$)/.test(String(call[0]))); +} + +describe("useEveVoice", () => { + it("configures realtime with a stable voice session setup URL", async () => { + const { useEveVoice } = await import("#react/voice.js"); + + function TestComponent() { + useEveVoice({ voiceSessionId: "voice-1" }); + return null; + } + + act(() => { + create(createElement(TestComponent)); + }); + + expect(realtimeOptions).toHaveLength(1); + expect(realtimeOptions[0].api.token).toBe( + "/eve/v1/realtime-speech/setup?voiceSessionId=voice-1", + ); + expect(realtimeOptions[0].model).toMatchObject({ + modelId: "openai/gpt-realtime-2", + provider: "gateway.realtime", + specificationVersion: "v4", + }); + expect(realtimeOptions[0].sessionConfig.outputModalities).toEqual(["audio"]); + expect( + realtimeOptions[0].model.getWebSocketConfig({ token: "vcst_test", url: "wss://gateway" }), + ).toEqual({ + protocols: ["ai-gateway-realtime.v1", "ai-gateway-auth.vcst_test"], + url: "wss://gateway", + }); + }); + + it("bridges finalized transcription into durable session turns and speaks the reply", async () => { + const fetch = sessionFetchMock([ + { sessionId: "session-1", events: [completedMessageEvent("Agent reply"), waiting()] }, + { sessionId: "session-1", events: [completedMessageEvent("Second reply"), waiting()] }, + ]); + vi.stubGlobal("fetch", fetch); + + const { useEveVoice } = await import("#react/voice.js"); + const onReply = vi.fn(); + + function TestComponent() { + useEveVoice({ context: ["voice context"], onReply, voiceSessionId: "voice-1" }); + return null; + } + + act(() => { + create(createElement(TestComponent)); + }); + + realtimeOptions[0].onEvent({ + itemId: "item-1", + raw: {}, + transcript: "Hello over speech", + type: "input-transcription-completed", + }); + + await vi.waitFor(() => expect(onReply).toHaveBeenCalled()); + + // First turn creates the session and consumes its event stream. + const firstPost = postCalls(fetch)[0]!; + expect(String(firstPost[0])).toBe("/eve/v1/session"); + expect(JSON.parse((firstPost[1] as RequestInit).body as string)).toEqual({ + message: "Hello over speech", + clientContext: ["voice context"], + }); + + expect(onReply).toHaveBeenCalledWith({ + message: "Hello over speech", + sessionId: "session-1", + streamIndex: 2, + text: "Agent reply", + }); + expect(realtimeState.sendEvent).toHaveBeenCalledWith({ + type: "conversation-item-create", + item: { type: "text-message", role: "user", text: "EVE_SPEAK:\nAgent reply" }, + }); + expect(realtimeState.requestResponse).toHaveBeenCalledWith({ modalities: ["audio"] }); + + // Second turn continues the same session and resumes the stream cursor. + realtimeOptions[0].onEvent({ + itemId: "item-2", + raw: {}, + transcript: "Second message", + type: "input-transcription-completed", + }); + + await vi.waitFor(() => expect(postCalls(fetch)).toHaveLength(2)); + + const secondPost = postCalls(fetch)[1]!; + expect(String(secondPost[0])).toBe("/eve/v1/session/session-1"); + const secondStream = streamCalls(fetch).at(-1)!; + expect(String(secondStream[0])).toContain("startIndex=2"); + }); + + it("speaks the configured fallback when a turn fails without producing text", async () => { + const fetch = sessionFetchMock([{ sessionId: "session-1", events: [sessionFailed()] }]); + vi.stubGlobal("fetch", fetch); + + const { useEveVoice } = await import("#react/voice.js"); + const onReply = vi.fn(); + + function TestComponent() { + useEveVoice({ + fallbackReply: "Sorry, please try again.", + onReply, + voiceSessionId: "voice-1", + }); + return null; + } + + act(() => { + create(createElement(TestComponent)); + }); + + realtimeOptions[0].onEvent({ + itemId: "item-1", + raw: {}, + transcript: "Hello", + type: "input-transcription-completed", + }); + + await vi.waitFor(() => + expect(realtimeState.sendEvent).toHaveBeenCalledWith({ + type: "conversation-item-create", + item: { type: "text-message", role: "user", text: "EVE_SPEAK:\nSorry, please try again." }, + }), + ); + expect(onReply).not.toHaveBeenCalled(); + }); + + it("ignores unsolicited model responses", async () => { + const { useEveVoice } = await import("#react/voice.js"); + + function TestComponent() { + useEveVoice({ voiceSessionId: "voice-1" }); + return null; + } + + act(() => { + create(createElement(TestComponent)); + }); + + realtimeOptions[0].onEvent({ raw: {}, responseId: "response-1", type: "response-created" }); + + expect(realtimeState.cancelResponse).not.toHaveBeenCalled(); + expect(realtimeState.requestResponse).not.toHaveBeenCalled(); + }); + + it("does not suppress finalized user transcripts after an adapter response starts", async () => { + const fetch = sessionFetchMock([ + { sessionId: "session-1", events: [completedMessageEvent("Agent reply"), waiting()] }, + ]); + vi.stubGlobal("fetch", fetch); + const { useEveVoice } = await import("#react/voice.js"); + const onReply = vi.fn(); + + function TestComponent() { + useEveVoice({ onReply, voiceSessionId: "voice-1" }); + return null; + } + + act(() => { + create(createElement(TestComponent)); + }); + + // Some realtime adapters create a placeholder response before the final + // user transcript arrives. That must not drop the user's durable Eve turn. + realtimeOptions[0].onEvent({ raw: {}, responseId: "auto-1", type: "response-created" }); + realtimeOptions[0].onEvent({ + itemId: "item-1", + raw: {}, + transcript: "Hello from the user", + type: "input-transcription-completed", + }); + + await vi.waitFor(() => expect(onReply).toHaveBeenCalled()); + expect(postCalls(fetch)).toHaveLength(1); + }); + + it("passes each transcript's own itemId to onTranscript", async () => { + const { useEveVoice } = await import("#react/voice.js"); + const seen: Array<{ itemId: string; transcript: string }> = []; + + function TestComponent() { + useEveVoice({ + voiceSessionId: "voice-1", + onTranscript: ({ itemId, transcript }) => { + seen.push({ itemId, transcript }); + }, + }); + return null; + } + + act(() => { + create(createElement(TestComponent)); + }); + + // Both finalize before the serialized turn queue drains; each turn must + // report the itemId captured at enqueue time, not the latest one. + realtimeOptions[0].onEvent({ + itemId: "item-1", + raw: {}, + transcript: "first", + type: "input-transcription-completed", + }); + realtimeOptions[0].onEvent({ + itemId: "item-2", + raw: {}, + transcript: "second", + type: "input-transcription-completed", + }); + + await vi.waitFor(() => expect(seen).toHaveLength(2)); + expect(seen).toEqual([ + { itemId: "item-1", transcript: "first" }, + { itemId: "item-2", transcript: "second" }, + ]); + }); + + it("suppresses transcriptions that arrive while the Eve reply is speaking", async () => { + const fetch = sessionFetchMock([ + { sessionId: "session-1", events: [completedMessageEvent("Agent reply"), waiting()] }, + ]); + vi.stubGlobal("fetch", fetch); + const { useEveVoice } = await import("#react/voice.js"); + + function TestComponent() { + useEveVoice({ voiceSessionId: "voice-1" }); + return null; + } + + act(() => { + create(createElement(TestComponent)); + }); + + realtimeOptions[0].onEvent({ + itemId: "item-1", + raw: {}, + transcript: "First utterance", + type: "input-transcription-completed", + }); + await vi.waitFor(() => expect(postCalls(fetch)).toHaveLength(1)); + + realtimeOptions[0].onEvent({ raw: {}, responseId: "response-1", type: "response-created" }); + realtimeOptions[0].onEvent({ + itemId: "item-2", + raw: {}, + transcript: "Agent reply", + type: "input-transcription-completed", + }); + + expect(postCalls(fetch)).toHaveLength(1); + }); + + it("does not run client turns in gateway-control mode", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + const { useEveVoice } = await import("#react/voice.js"); + + function TestComponent() { + useEveVoice({ voiceSessionId: "voice-1", controlMode: true }); + return null; + } + + act(() => { + create(createElement(TestComponent)); + }); + + // Gateway drives turns over its control socket; the browser only streams + // audio, so a finalized transcript must not start a client-side turn. + realtimeOptions[0].onEvent({ + itemId: "item-1", + raw: {}, + transcript: "hello", + type: "input-transcription-completed", + }); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it("caps the live voice transcript and keeps the most recent messages", async () => { + const { useEveVoice } = await import("#react/voice.js"); + + let latest: ReturnType | undefined; + function TestComponent() { + latest = useEveVoice({ voiceSessionId: "voice-1", controlMode: true }); + return null; + } + + act(() => { + create(createElement(TestComponent)); + }); + + await act(async () => { + for (let i = 0; i < 300; i++) { + realtimeOptions.at(-1).onEvent({ + itemId: `item-${i}`, + raw: {}, + transcript: `reply ${i}`, + type: "input-transcription-completed", + }); + } + }); + + await vi.waitFor(() => expect(latest?.messages).toHaveLength(256)); + expect(latest?.messages.at(-1)?.text).toBe("reply 299"); + expect(latest?.messages[0]?.text).toBe("reply 44"); + }); + + it("ignores empty transcription completions", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + const { useEveVoice } = await import("#react/voice.js"); + + function TestComponent() { + useEveVoice({ voiceSessionId: "voice-1" }); + return null; + } + + act(() => { + create(createElement(TestComponent)); + }); + + realtimeOptions[0].onEvent({ + itemId: "empty-item", + raw: {}, + transcript: " ", + type: "input-transcription-completed", + }); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it("releases the microphone and skips capture when the realtime connection fails", async () => { + const stop = vi.fn(); + const getUserMedia = vi.fn(async () => ({ getTracks: () => [{ stop }] })); + vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); + realtimeState.connect.mockImplementationOnce(async () => { + realtimeOptions[0].onError(new Error("realtime offline")); + }); + + const { useEveVoice } = await import("#react/voice.js"); + const onError = vi.fn(); + let voice: ReturnType | undefined; + function TestComponent() { + voice = useEveVoice({ onError, voiceSessionId: "voice-1" }); + return null; + } + + act(() => { + create(createElement(TestComponent)); + }); + + await act(async () => { + await voice!.start(); + }); + + expect(getUserMedia).toHaveBeenCalledTimes(1); + expect(stop).toHaveBeenCalledTimes(1); + expect(realtimeState.startAudioCapture).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: "realtime offline" })); + }); + + it("ignores re-entrant start() calls while a connection is in flight", async () => { + const stop = vi.fn(); + const getUserMedia = vi.fn(async () => ({ getTracks: () => [{ stop }] })); + vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); + + const { useEveVoice } = await import("#react/voice.js"); + let voice: ReturnType | undefined; + function TestComponent() { + voice = useEveVoice({ voiceSessionId: "voice-1" }); + return null; + } + + act(() => { + create(createElement(TestComponent)); + }); + + // The second call is synchronous, before the first start() resolves its + // microphone request, so the re-entrancy guard must short-circuit it. + await act(async () => { + await Promise.all([voice!.start(), voice!.start()]); + }); + + expect(getUserMedia).toHaveBeenCalledTimes(1); + expect(realtimeState.connect).toHaveBeenCalledTimes(1); + expect(realtimeState.startAudioCapture).toHaveBeenCalledTimes(1); + }); + + it("releases the microphone when a realtime error surfaces after connecting", async () => { + const stop = vi.fn(); + const getUserMedia = vi.fn(async () => ({ getTracks: () => [{ stop }] })); + vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); + + const { useEveVoice } = await import("#react/voice.js"); + let voice: ReturnType | undefined; + function TestComponent() { + voice = useEveVoice({ voiceSessionId: "voice-1" }); + return null; + } + + act(() => { + create(createElement(TestComponent)); + }); + + await act(async () => { + await voice!.start(); + }); + + expect(realtimeState.startAudioCapture).toHaveBeenCalledTimes(1); + expect(stop).not.toHaveBeenCalled(); + + act(() => { + realtimeOptions[0].onError(new Error("socket dropped")); + }); + + expect(realtimeState.stopAudioCapture).toHaveBeenCalled(); + expect(stop).toHaveBeenCalledTimes(1); + }); +}); + +function waiting() { + return { type: "session.waiting", data: { wait: "next-user-message" } }; +} + +function sessionFailed() { + return { type: "session.failed", data: { reason: "boom" } }; +} diff --git a/packages/eve/src/react/voice.ts b/packages/eve/src/react/voice.ts new file mode 100644 index 000000000..dfcf0046a --- /dev/null +++ b/packages/eve/src/react/voice.ts @@ -0,0 +1,792 @@ +"use client"; + +import { experimental_useRealtime } from "@ai-sdk/react"; +import { Client } from "#client/client.js"; +import type { ClientSession } from "#client/session.js"; +import type { ClientAuth, HeadersValue } from "#client/types.js"; +import { EVE_VOICE_SETUP_ROUTE_PATH, voiceSetupUrl } from "#client/voice.js"; +import type { + Experimental_RealtimeClientEvent, + Experimental_RealtimeModel, + Experimental_RealtimeServerEvent, + Experimental_RealtimeSessionConfig, +} from "ai"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +const DEFAULT_MODEL = "openai/gpt-realtime-2"; +const GATEWAY_REALTIME_SUBPROTOCOL = "ai-gateway-realtime.v1"; +const GATEWAY_AUTH_SUBPROTOCOL_PREFIX = "ai-gateway-auth."; +const EVE_SPEAK_PREFIX = "EVE_SPEAK:"; +const ECHO_SUPPRESSION_MS = 900; +// Bounds the deduplication set so a long-lived session does not grow it without +// limit; finalized transcription item ids are only revisited within a few turns. +const MAX_TRACKED_INPUT_ITEMS = 256; +// Bounds the rendered voice transcript so a long-lived session does not grow the +// messages array without limit; mirrors MAX_TRACKED_INPUT_ITEMS. +const MAX_VOICE_MESSAGES = 256; + +type StoppableMediaStream = { + getTracks(): readonly { stop(): void }[]; +}; + +export interface UseEveVoiceOptions { + readonly context?: string | readonly string[]; + readonly model?: string; + readonly sessionConfig?: EveVoiceConfig; + readonly setupUrl?: string; + readonly voiceSessionId?: string; + /** + * Gateway-owned control mode (A-lite). When true, AI Gateway drives durable + * turns over its server-side control socket, so the browser only streams + * audio: the hook does not run client-side `/eve/v1/session` turns, call + * `onTranscript`, or speak replies (Gateway injects TTS). Use this when the + * channel is configured with `control`. + */ + readonly controlMode?: boolean; + /** + * Spoken when a turn fails before producing any assistant text. Off by + * default: a failed turn surfaces through `onError`/`status` instead of + * speaking a canned line, and a fallback is never spoken when the turn + * already produced a usable reply. + */ + readonly fallbackReply?: string; + /** + * Run voice turns against an existing session/client instead of an internal + * one. Pass `session` to share a `ClientSession`, `client` to reuse an + * authenticated `Client`, or `host`/`auth`/`headers` to configure the + * internal client. Read once when the hook first renders; remount to change. + */ + readonly client?: Client; + readonly session?: ClientSession; + readonly host?: string; + readonly auth?: ClientAuth; + readonly headers?: HeadersValue; + readonly onError?: (error: Error) => void; + readonly onEvent?: (event: EveVoiceEvent) => void; + readonly onTranscript?: (input: { + readonly itemId: string; + readonly transcript: string; + readonly voiceSessionId: string; + }) => Promise | string | void; + readonly onReply?: (reply: { + readonly message: string; + readonly sessionId: string; + readonly streamIndex: number; + readonly text: string; + }) => void; +} + +export type EveVoiceActivity = + | "ready" + | "connecting" + | "listening" + | "user-speaking" + | "assistant-speaking" + | "error"; + +export type EveVoiceStatus = "disconnected" | "connecting" | "connected" | "error"; + +/** + * One rendered turn in the live voice transcript. Works in both modes: in + * Gateway-control mode it is built from the realtime transcript events the + * browser receives (user `input-transcription-completed`, assistant + * `audio-transcript-delta`); in client-driven mode it mirrors the durable turn + * the hook runs. Lets apps render the conversation without reaching into raw + * provider event shapes. + */ +export interface EveVoiceMessage { + readonly id: string; + readonly role: "user" | "assistant"; + readonly text: string; +} + +// Keeps the most recent messages; new entries are appended, so the oldest are +// evicted from the front and a streaming (newest) assistant message is never +// dropped mid-stream. +function capVoiceMessages(messages: readonly EveVoiceMessage[]): readonly EveVoiceMessage[] { + return messages.length > MAX_VOICE_MESSAGES + ? messages.slice(messages.length - MAX_VOICE_MESSAGES) + : messages; +} + +function upsertUserVoiceMessage( + messages: readonly EveVoiceMessage[], + id: string, + text: string, +): readonly EveVoiceMessage[] { + if (text.length === 0 || messages.some((message) => message.id === id)) return messages; + return capVoiceMessages([...messages, { id, role: "user", text }]); +} + +function appendAssistantVoiceDelta( + messages: readonly EveVoiceMessage[], + id: string, + delta: string, +): readonly EveVoiceMessage[] { + const index = messages.findIndex((message) => message.id === id); + const existing = index === -1 ? undefined : messages[index]; + if (existing === undefined) { + return capVoiceMessages([...messages, { id, role: "assistant", text: delta }]); + } + const next = messages.slice(); + next[index] = { id, role: "assistant", text: existing.text + delta }; + return next; +} + +function setAssistantVoiceText( + messages: readonly EveVoiceMessage[], + id: string, + text: string, +): readonly EveVoiceMessage[] { + const index = messages.findIndex((message) => message.id === id); + if (index === -1) return capVoiceMessages([...messages, { id, role: "assistant", text }]); + const next = messages.slice(); + next[index] = { id, role: "assistant", text }; + return next; +} + +export interface EveVoiceConfig { + readonly instructions?: string; + readonly inputAudioTranscription?: { + readonly language?: string; + readonly model?: string; + readonly prompt?: string; + }; + readonly outputAudioTranscription?: { + readonly language?: string; + readonly model?: string; + readonly prompt?: string; + }; + readonly outputAudioFormat?: { + readonly rate?: number; + readonly type: string; + }; + readonly outputModalities?: ("audio" | "text")[]; + readonly providerOptions?: Record; + readonly turnDetection?: { + readonly prefixPaddingMs?: number; + readonly silenceDurationMs?: number; + readonly threshold?: number; + readonly type: "disabled" | "semantic-vad" | "server-vad"; + } | null; + readonly voice?: string; +} + +export type EveVoiceEvent = + | { readonly raw: unknown; readonly sessionId?: string; readonly type: "session-created" } + | { readonly raw: unknown; readonly type: "session-updated" } + | { readonly itemId?: string; readonly raw: unknown; readonly type: "speech-started" } + | { readonly itemId?: string; readonly raw: unknown; readonly type: "speech-stopped" } + | { + readonly itemId?: string; + readonly previousItemId?: string; + readonly raw: unknown; + readonly type: "audio-committed"; + } + | { + readonly item: unknown; + readonly itemId: string; + readonly raw: unknown; + readonly type: "conversation-item-added"; + } + | { + readonly itemId: string; + readonly raw: unknown; + readonly transcript: string; + readonly type: "input-transcription-completed"; + } + | { readonly raw: unknown; readonly responseId: string; readonly type: "response-created" } + | { + readonly raw: unknown; + readonly responseId: string; + readonly status: string; + readonly type: "response-done"; + } + | { + readonly itemId: string; + readonly raw: unknown; + readonly responseId: string; + readonly type: "output-item-added"; + } + | { + readonly itemId: string; + readonly raw: unknown; + readonly responseId: string; + readonly type: "output-item-done"; + } + | { + readonly itemId: string; + readonly raw: unknown; + readonly responseId: string; + readonly type: "content-part-added"; + } + | { + readonly itemId: string; + readonly raw: unknown; + readonly responseId: string; + readonly type: "content-part-done"; + } + | { + readonly delta: string; + readonly itemId: string; + readonly raw: unknown; + readonly responseId: string; + readonly type: "audio-delta"; + } + | { + readonly itemId: string; + readonly raw: unknown; + readonly responseId: string; + readonly type: "audio-done"; + } + | { + readonly delta: string; + readonly itemId: string; + readonly raw: unknown; + readonly responseId: string; + readonly type: "audio-transcript-delta"; + } + | { + readonly itemId: string; + readonly raw: unknown; + readonly responseId: string; + readonly transcript?: string; + readonly type: "audio-transcript-done"; + } + | { + readonly delta: string; + readonly itemId: string; + readonly raw: unknown; + readonly responseId: string; + readonly type: "text-delta"; + } + | { + readonly itemId: string; + readonly raw: unknown; + readonly responseId: string; + readonly text?: string; + readonly type: "text-done"; + } + | { + readonly callId: string; + readonly delta: string; + readonly itemId: string; + readonly raw: unknown; + readonly responseId: string; + readonly type: "function-call-arguments-delta"; + } + | { + readonly arguments: string; + readonly callId: string; + readonly itemId: string; + readonly name: string; + readonly raw: unknown; + readonly responseId: string; + readonly type: "function-call-arguments-done"; + } + | { + readonly code?: string; + readonly message: string; + readonly raw: unknown; + readonly type: "error"; + } + | { readonly raw: unknown; readonly rawType: string; readonly type: "custom" }; + +export interface UseEveVoiceResult { + readonly error: Error | undefined; + readonly activity: EveVoiceActivity; + readonly isCapturing: boolean; + readonly isPlaying: boolean; + readonly isUserSpeaking: boolean; + readonly lastReply: string | undefined; + /** Live voice transcript for both sides; render this instead of raw events. */ + readonly messages: readonly EveVoiceMessage[]; + /** True between a finalized user transcript and the assistant's first words. */ + readonly isThinking: boolean; + readonly sessionId: string | undefined; + readonly speak: (text: string) => void; + readonly status: EveVoiceStatus; + readonly stopPlayback: () => void; + readonly streamIndex: number; + readonly voiceSessionId: string; + start(): Promise; + stop(): void; +} + +export function useEveVoice(options: UseEveVoiceOptions = {}): UseEveVoiceResult { + const voiceSessionIdRef = useRef(options.voiceSessionId ?? crypto.randomUUID()); + const voiceSessionId = voiceSessionIdRef.current; + const sessionRef = useRef(undefined); + if (sessionRef.current === undefined) { + sessionRef.current = resolveVoiceSession(options); + } + const session = sessionRef.current; + const [error, setError] = useState(undefined); + const [isUserSpeaking, setIsUserSpeaking] = useState(false); + const [lastReply, setLastReply] = useState(undefined); + const [messages, setMessages] = useState([]); + const [isThinking, setIsThinking] = useState(false); + const [sessionId, setSessionId] = useState(session.state.sessionId); + const [streamIndex, setStreamIndex] = useState(session.state.streamIndex); + const ignoreInputUntilRef = useRef(0); + const processedInputItemsRef = useRef(new Set()); + const requestResponseRef = useRef<((options?: { modalities?: string[] }) => void) | undefined>( + undefined, + ); + const responseInFlightRef = useRef(false); + const mediaStreamRef = useRef(null); + const lastErrorRef = useRef(undefined); + const startingRef = useRef(false); + const stopAudioCaptureRef = useRef<(() => void) | undefined>(undefined); + + const model = useMemo(() => resolveRealtimeModel(options.model), [options.model]); + const setupUrl = useMemo( + () => voiceSetupUrl(options.setupUrl ?? EVE_VOICE_SETUP_ROUTE_PATH, voiceSessionId), + [options.setupUrl, voiceSessionId], + ); + const sessionConfig = useMemo( + () => + buildSessionConfig({ + sessionConfig: options.sessionConfig, + voiceSessionId, + }), + [options.sessionConfig, voiceSessionId], + ); + + const handleError = useCallback( + (nextError: Error) => { + lastErrorRef.current = nextError; + setError(nextError); + setIsUserSpeaking(false); + options.onError?.(nextError); + }, + [options.onError], + ); + + const handleRealtimeError = useCallback( + (nextError: Error) => { + // A realtime/transport failure leaves the session unusable — including one + // that surfaces after a successful connect — so release the microphone. + // Per-turn failures go through handleError directly and keep the mic open. + stopAudioCaptureRef.current?.(); + mediaStreamRef.current?.getTracks().forEach((track) => track.stop()); + mediaStreamRef.current = null; + handleError(nextError); + }, + [handleError], + ); + + const speakEveReply = useCallback((text: string) => { + const trimmed = text.trim(); + if (trimmed.length === 0) return; + + sendEventRef.current?.({ + type: "conversation-item-create", + item: { + type: "text-message", + role: "user", + text: `${EVE_SPEAK_PREFIX}\n${trimmed}`, + }, + }); + requestResponseRef.current?.({ modalities: ["audio"] }); + }, []); + + const speakFallbackReply = useCallback(() => { + const fallback = options.fallbackReply; + if (fallback === undefined || fallback.trim().length === 0) return; + setLastReply(fallback); + speakEveReply(fallback); + }, [options.fallbackReply, speakEveReply]); + + const runEveTurn = useCallback( + async (message: string, itemId: string) => { + setMessages((prev) => upsertUserVoiceMessage(prev, `user:${itemId}`, message.trim())); + setIsThinking(true); + if (options.onTranscript !== undefined) { + const reply = await options.onTranscript({ + itemId, + transcript: message, + voiceSessionId, + }); + setIsThinking(false); + if (typeof reply === "string" && reply.trim().length > 0) { + setLastReply(reply); + setMessages((prev) => setAssistantVoiceText(prev, `assistant:${itemId}`, reply.trim())); + speakEveReply(reply); + } + return; + } + + let replyText: string | undefined; + let spokeReply = false; + try { + const turn: { message: string; clientContext?: string | readonly string[] } = { message }; + if (options.context !== undefined) turn.clientContext = options.context; + const response = await session.send(turn); + + let failed = false; + for await (const event of response) { + if ( + event.type === "message.completed" && + event.data.finishReason !== "tool-calls" && + typeof event.data.message === "string" && + event.data.message.length > 0 + ) { + replyText = event.data.message; + } else if (event.type === "session.failed") { + failed = true; + } + } + + setSessionId(session.state.sessionId); + setStreamIndex(session.state.streamIndex); + + if (replyText !== undefined && replyText.trim().length > 0) { + const finalReply = replyText.trim(); + setLastReply(replyText); + setMessages((prev) => setAssistantVoiceText(prev, `assistant:${itemId}`, finalReply)); + setIsThinking(false); + options.onReply?.({ + message, + sessionId: response.sessionId, + streamIndex: session.state.streamIndex, + text: replyText, + }); + speakEveReply(replyText); + spokeReply = true; + return; + } + + // A terminal failure that produced no assistant text would be dead + // air, so optionally speak the configured fallback. + setIsThinking(false); + if (failed) speakFallbackReply(); + } catch (cause) { + setIsThinking(false); + // Only fall back when this turn produced no usable reply, so a late + // transport error cannot overwrite or double-speak a real answer. + if (!spokeReply && (replyText === undefined || replyText.trim().length === 0)) { + speakFallbackReply(); + } + throw cause; + } + }, + [ + options.context, + options.onReply, + options.onTranscript, + session, + speakEveReply, + speakFallbackReply, + voiceSessionId, + ], + ); + + const turnQueueRef = useRef(Promise.resolve()); + const enqueueEveTurn = useCallback( + (message: string, itemId: string) => { + turnQueueRef.current = turnQueueRef.current + .catch(() => undefined) + .then(() => runEveTurn(message, itemId)) + .catch((cause) => { + const nextError = cause instanceof Error ? cause : new Error(String(cause)); + handleError(nextError); + }); + }, + [handleError, runEveTurn], + ); + + const handleEvent = useCallback( + (event: Experimental_RealtimeServerEvent) => { + switch (event.type) { + case "response-created": + // Suppress user transcripts for the lifetime of ANY model response, + // not only ones solicited via speak(). A server-VAD auto-response + // would otherwise play with no in-flight flag set, and its own audio + // could be transcribed back and enqueued as a spurious user turn. + responseInFlightRef.current = true; + break; + case "response-done": + case "error": + responseInFlightRef.current = false; + ignoreInputUntilRef.current = Date.now() + ECHO_SUPPRESSION_MS; + setIsThinking(false); + break; + case "speech-started": + setIsUserSpeaking(true); + break; + case "speech-stopped": + case "audio-committed": + setIsUserSpeaking(false); + break; + case "audio-transcript-delta": + // Gateway-control mode: the assistant's spoken words stream back as + // transcript deltas; accumulate them into the live feed. + if (options.controlMode) { + setIsThinking(false); + setMessages((prev) => + appendAssistantVoiceDelta(prev, `assistant:${event.responseId}`, event.delta), + ); + } + break; + case "audio-transcript-done": + if (options.controlMode) { + setIsThinking(false); + const finalText = event.transcript?.trim(); + if (finalText !== undefined && finalText.length > 0) { + setMessages((prev) => + setAssistantVoiceText(prev, `assistant:${event.responseId}`, finalText), + ); + } + } + break; + case "input-transcription-completed": + setIsUserSpeaking(false); + // In Gateway-control mode the server drives turns over its control + // socket; the browser only streams audio and never runs client turns. + // It still surfaces the finalized transcript, which we render and use + // to flip into the Thinking… state until the assistant speaks. + if (options.controlMode) { + { + const transcript = event.transcript.trim(); + if (transcript.length > 0) { + setMessages((prev) => + upsertUserVoiceMessage(prev, `user:${event.itemId}`, transcript), + ); + setIsThinking(true); + } + } + break; + } + if (processedInputItemsRef.current.has(event.itemId)) { + break; + } + processedInputItemsRef.current.add(event.itemId); + if (processedInputItemsRef.current.size > MAX_TRACKED_INPUT_ITEMS) { + const oldest = processedInputItemsRef.current.values().next().value; + if (oldest !== undefined) processedInputItemsRef.current.delete(oldest); + } + const transcript = event.transcript.trim(); + if (transcript.length === 0) { + break; + } + if (Date.now() < ignoreInputUntilRef.current) { + break; + } + enqueueEveTurn(transcript, event.itemId); + break; + } + options.onEvent?.(event as EveVoiceEvent); + }, + [enqueueEveTurn, options.controlMode, options.onEvent], + ); + + const sendEventRef = useRef<((event: Experimental_RealtimeClientEvent) => void) | undefined>( + undefined, + ); + const realtime = experimental_useRealtime({ + api: { token: setupUrl }, + model, + onError: handleRealtimeError, + onEvent: handleEvent, + sessionConfig, + }); + requestResponseRef.current = realtime.requestResponse; + sendEventRef.current = realtime.sendEvent; + stopAudioCaptureRef.current = realtime.stopAudioCapture; + + const stop = useCallback(() => { + realtime.stopAudioCapture(); + realtime.stopPlayback(); + realtime.disconnect(); + ignoreInputUntilRef.current = 0; + processedInputItemsRef.current.clear(); + responseInFlightRef.current = false; + setIsUserSpeaking(false); + mediaStreamRef.current?.getTracks().forEach((track) => track.stop()); + mediaStreamRef.current = null; + }, [realtime]); + const stopRef = useRef(stop); + stopRef.current = stop; + + const start = useCallback(async () => { + // Ignore re-entrant starts: a second in-flight or already-live session + // would acquire another microphone stream and orphan the previous one. + if ( + startingRef.current || + realtime.status === "connecting" || + realtime.status === "connected" + ) { + return; + } + startingRef.current = true; + setError(undefined); + lastErrorRef.current = undefined; + try { + const mediaStream = await getMicrophoneStream(); + mediaStreamRef.current = mediaStream; + await realtime.connect(); + // The AI SDK's connect() resolves even when the realtime session fails to + // open: it routes the failure through onError instead of rejecting. Treat + // a captured error as a thrown connection failure so the microphone is + // released and audio capture never starts against a dead session. + if (lastErrorRef.current !== undefined) { + throw lastErrorRef.current; + } + realtime.startAudioCapture(mediaStream as Parameters[0]); + } catch (cause) { + mediaStreamRef.current?.getTracks().forEach((track) => track.stop()); + mediaStreamRef.current = null; + const nextError = cause instanceof Error ? cause : new Error(String(cause)); + // Avoid double-reporting when onError already surfaced this error. + if (nextError !== lastErrorRef.current) { + handleError(nextError); + } + } finally { + startingRef.current = false; + } + }, [handleError, realtime]); + + useEffect(() => () => stopRef.current(), []); + + return { + activity: resolveActivity({ + isPlaying: realtime.isPlaying, + isUserSpeaking, + status: realtime.status, + }), + error, + isCapturing: realtime.isCapturing, + isPlaying: realtime.isPlaying, + isThinking, + isUserSpeaking, + lastReply, + messages, + sessionId, + speak: speakEveReply, + start, + status: realtime.status, + stop, + stopPlayback: realtime.stopPlayback, + streamIndex, + voiceSessionId, + }; +} + +function resolveActivity(input: { + readonly isPlaying: boolean; + readonly isUserSpeaking: boolean; + readonly status: EveVoiceStatus; +}): EveVoiceActivity { + if (input.status === "error") return "error"; + if (input.status === "connecting") return "connecting"; + if (input.status !== "connected") return "ready"; + if (input.isUserSpeaking) return "user-speaking"; + if (input.isPlaying) return "assistant-speaking"; + return "listening"; +} + +function resolveVoiceSession(options: UseEveVoiceOptions): ClientSession { + if (options.session !== undefined) return options.session; + if (options.client !== undefined) return options.client.session(); + const clientOptions: { host: string; auth?: ClientAuth; headers?: HeadersValue } = { + host: options.host ?? "", + }; + if (options.auth !== undefined) clientOptions.auth = options.auth; + if (options.headers !== undefined) clientOptions.headers = options.headers; + const client = new Client(clientOptions); + return client.session(); +} + +function buildSessionConfig(input: { + readonly sessionConfig: EveVoiceConfig | undefined; + readonly voiceSessionId: string; +}): Partial { + const baseGatewayOptions = { + tags: ["eve", "realtime-speech"], + user: input.voiceSessionId, + }; + const providerOptions = input.sessionConfig?.providerOptions; + const gatewayOptions = asRecord(providerOptions?.gateway); + + return { + instructions: [ + "You are a speech transport adapter for an Eve agent, not the assistant.", + "Do not answer user speech directly and do not mention tools, waiting, or checking.", + `Only speak when you receive a user message beginning with ${EVE_SPEAK_PREFIX}`, + "When you receive that marker, read only the text after it exactly.", + ].join(" "), + inputAudioTranscription: {}, + outputAudioTranscription: {}, + outputModalities: ["audio"], + turnDetection: { type: "server-vad" }, + voice: "alloy", + ...input.sessionConfig, + providerOptions: { + ...providerOptions, + gateway: { + ...baseGatewayOptions, + ...gatewayOptions, + }, + }, + }; +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function resolveRealtimeModel(model: string | Experimental_RealtimeModel | undefined) { + if (typeof model === "object" && model !== null) return model; + return createGatewayRealtimeModel(model ?? DEFAULT_MODEL); +} + +function createGatewayRealtimeModel(modelId: string): Experimental_RealtimeModel { + return { + specificationVersion: "v4", + provider: "gateway.realtime", + modelId, + doCreateClientSecret() { + throw new Error( + "Eve voice mints Gateway realtime client secrets through the setup route, not in the browser.", + ); + }, + getWebSocketConfig(options) { + return { + url: options.url, + protocols: [ + GATEWAY_REALTIME_SUBPROTOCOL, + `${GATEWAY_AUTH_SUBPROTOCOL_PREFIX}${options.token}`, + ], + }; + }, + parseServerEvent(raw: unknown): Experimental_RealtimeServerEvent { + return raw as Experimental_RealtimeServerEvent; + }, + serializeClientEvent(event: Experimental_RealtimeClientEvent): unknown { + return event; + }, + buildSessionConfig(config: Experimental_RealtimeSessionConfig): unknown { + return config; + }, + }; +} + +async function getMicrophoneStream(): Promise { + const mediaDevices = ( + globalThis as { + readonly navigator?: { + readonly mediaDevices?: { + getUserMedia(input: { readonly audio: true }): Promise; + }; + }; + } + ).navigator?.mediaDevices; + + if (mediaDevices === undefined) { + throw new Error("Microphone capture is not available in this environment."); + } + return mediaDevices.getUserMedia({ audio: true }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fa5cef66..9e9079d09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ catalogs: '@ai-sdk/provider': specifier: 4.0.0-beta.19 version: 4.0.0-beta.19 + '@ai-sdk/react': + specifier: 4.0.0-beta.182 + version: 4.0.0-beta.182 '@types/node': specifier: 25.9.1 version: 25.9.1 @@ -67,6 +70,9 @@ catalogs: specifier: 4.4.3 version: 4.4.3 +overrides: + '@ai-sdk/gateway': file:vendor/ai-sdk-gateway-4.0.0-beta.110-speech-engine.tgz + importers: .: @@ -289,6 +295,12 @@ importers: apps/frameworks/next: dependencies: + '@ai-sdk/gateway': + specifier: file:../../../vendor/ai-sdk-gateway-4.0.0-beta.110-speech-engine.tgz + version: file:vendor/ai-sdk-gateway-4.0.0-beta.110-speech-engine.tgz(zod@4.4.3) + '@ai-sdk/react': + specifier: 'catalog:' + version: 4.0.0-beta.182(react@19.2.6)(zod@4.4.3) '@auth/core': specifier: 0.41.2 version: 0.41.2 @@ -735,6 +747,9 @@ importers: packages/eve: dependencies: + '@ai-sdk/gateway': + specifier: file:../../vendor/ai-sdk-gateway-4.0.0-beta.110-speech-engine.tgz + version: file:vendor/ai-sdk-gateway-4.0.0-beta.110-speech-engine.tgz(zod@4.4.3) '@opentelemetry/api': specifier: ^1.0.0 version: 1.9.1 @@ -766,6 +781,9 @@ importers: '@ai-sdk/provider': specifier: 'catalog:' version: 4.0.0-beta.19 + '@ai-sdk/react': + specifier: 'catalog:' + version: 4.0.0-beta.182(react@19.2.6)(zod@4.4.3) '@chat-adapter/slack': specifier: 4.29.0 version: 4.29.0(ai@7.0.0-beta.178(zod@4.4.3))(zod@4.4.3) @@ -904,14 +922,9 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/gateway@3.0.120': - resolution: {integrity: sha512-MYKAeD2q7/sa1ZdqtL2tw0Me0B8Tok6Q/fhkJDhJl39dG8u+VBlWO9yk9lcdm784bM418o1EKObo4aOxs6+18Q==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/gateway@4.0.0-beta.109': - resolution: {integrity: sha512-W/1kLlPb6Bgbhwep3CA3R6do0HD7SXV5gyuz2XBLY1YABqgxYkw+IhEcjOYlmn9v+Tifjqy5yJqmWdSHMJhyPQ==} + '@ai-sdk/gateway@file:vendor/ai-sdk-gateway-4.0.0-beta.110-speech-engine.tgz': + resolution: {integrity: sha512-Twty+hibORBK2YBEnmV1MEkI1ukSXn0Alb4pLIweV/JDFhj6oF1AaLdrWwwbRwaJANwybLd9Y7AiibHQhvO5UA==, tarball: file:vendor/ai-sdk-gateway-4.0.0-beta.110-speech-engine.tgz} + version: 4.0.0-beta.110 engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -964,6 +977,12 @@ packages: peerDependencies: react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 + '@ai-sdk/react@4.0.0-beta.182': + resolution: {integrity: sha512-o9YwQ1QELhsuXcdg2ZfJ1GyHrtsXBSjMtlxdGnOW36Jkqiv0DT1yARI2PQKX0Ge0vsCNGIbPoJzNrkDzRTcG+g==} + engines: {node: '>=22'} + peerDependencies: + react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -11724,6 +11743,7 @@ packages: stream-to-promise@2.2.0: resolution: {integrity: sha512-HAGUASw8NT0k8JvIVutB2Y/9iBk7gpgEyAudXwNJmZERdMITGdajOa4VJfD/kNiA3TppQpTP4J+CtcHwdzKBAw==} + deprecated: Deprecated. Use node:stream/promises and node:stream/consumers instead. streamdown@2.5.0: resolution: {integrity: sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA==} @@ -12962,14 +12982,7 @@ snapshots: '@ai-sdk/provider-utils': 5.0.0-beta.49(zod@4.4.3) zod: 4.4.3 - '@ai-sdk/gateway@3.0.120(zod@4.4.3)': - dependencies: - '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) - '@vercel/oidc': 3.2.0 - zod: 4.4.3 - - '@ai-sdk/gateway@4.0.0-beta.109(zod@4.4.3)': + '@ai-sdk/gateway@file:vendor/ai-sdk-gateway-4.0.0-beta.110-speech-engine.tgz(zod@4.4.3)': dependencies: '@ai-sdk/provider': 4.0.0-beta.19 '@ai-sdk/provider-utils': 5.0.0-beta.49(zod@4.4.3) @@ -13036,6 +13049,18 @@ snapshots: transitivePeerDependencies: - zod + '@ai-sdk/react@4.0.0-beta.182(react@19.2.6)(zod@4.4.3)': + dependencies: + '@ai-sdk/mcp': 2.0.0-beta.66(zod@4.4.3) + '@ai-sdk/provider': 4.0.0-beta.19 + '@ai-sdk/provider-utils': 5.0.0-beta.49(zod@4.4.3) + ai: 7.0.0-beta.178(zod@4.4.3) + react: 19.2.6 + swr: 2.4.1(react@19.2.6) + throttleit: 2.1.0 + transitivePeerDependencies: + - zod + '@alloc/quick-lru@5.2.0': {} '@antfu/install-pkg@1.1.0': @@ -19235,7 +19260,7 @@ snapshots: ai@6.0.191(zod@4.4.3): dependencies: - '@ai-sdk/gateway': 3.0.120(zod@4.4.3) + '@ai-sdk/gateway': file:vendor/ai-sdk-gateway-4.0.0-beta.110-speech-engine.tgz(zod@4.4.3) '@ai-sdk/provider': 3.0.10 '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) '@opentelemetry/api': 1.9.1 @@ -19243,7 +19268,7 @@ snapshots: ai@7.0.0-beta.178(zod@4.4.3): dependencies: - '@ai-sdk/gateway': 4.0.0-beta.109(zod@4.4.3) + '@ai-sdk/gateway': file:vendor/ai-sdk-gateway-4.0.0-beta.110-speech-engine.tgz(zod@4.4.3) '@ai-sdk/provider': 4.0.0-beta.19 '@ai-sdk/provider-utils': 5.0.0-beta.49(zod@4.4.3) zod: 4.4.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a2b11d54d..936496ff2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,13 @@ packages: - e2e/fixtures/* - packages/* +# Temporary override: a locally-built @ai-sdk/gateway that adds realtime voice +# `control` mint support plus the shared speech-engine control protocol + codec +# + GatewaySpeechEngineSession helper. Vendored as a tarball so this branch is +# self-contained; drop it once the change lands upstream in @ai-sdk/gateway. +overrides: + "@ai-sdk/gateway": "file:vendor/ai-sdk-gateway-4.0.0-beta.110-speech-engine.tgz" + allowBuilds: "@mongodb-js/zstd": true "@nestjs/core": true @@ -19,6 +26,7 @@ allowBuilds: catalog: "@ai-sdk/anthropic": "4.0.0-beta.67" + "@ai-sdk/gateway": "4.0.0-beta.110" "@ai-sdk/google": "4.0.0-beta.82" "@ai-sdk/mcp": "2.0.0-beta.66" "@ai-sdk/openai": "4.0.0-beta.74" diff --git a/vendor/ai-sdk-gateway-4.0.0-beta.110-speech-engine.tgz b/vendor/ai-sdk-gateway-4.0.0-beta.110-speech-engine.tgz new file mode 100644 index 0000000000000000000000000000000000000000..fc112911b721e830bb126cbf782f0f3155edbac3 GIT binary patch literal 121437 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%0D@D$1{&t`nc=@F|GG58AF;rV+ zc=At;Mti~7sCqs$+qoCkptZ&q#-C$Y6|>g5jgI_lG#Upv)Pvo8mq$lfLrhUMIyWXG zV_qlgX^x4XxcR%ee3!YiS8~Yn@m}iXORRmownC;GWtippm`E23j-tRy)2c z(99k%LML<5--(UqStt31n4+RWzTi){G@0)WOHS!SmOt?liPe!d(e5T#&Px_SJ;wRH ze5KdZj`mWX{jB^I-crwJtyrJIz=&*kNl|PW?I2Gz>D=pmHv#}gK)JuTiDf8urKpb0 zZAO27XX1yntlk~x6t~g+lJ3gLiru@KdM${Hra)7Qs>^#XYbRcDh?Pe2_Tyt)Yfxd zHmoLBToy$t1`^9kURKDix*Cz&@TP4V6N_`fB5^UQPc|xE{44ODQ$0~>yDbjs2&LX` z9J=7Nwz;QQhxc8(yq7?6gWdt9#>*)Wvj3_;?L&Ra)(TG5kG%By#0cxgthqv5cSccE z_mxPS-@H=t>AlSu1mE&`U54N&8He4n7x06 zp@0ML?|;YTR=MX{1tb0yJ{ZamP!yTQp}S>EwOl1sOjzf)r)8gfO_M#OD_%H(O;

&NbD^qs-Ytq|s&@E>(^8fdHdH;X2)B2qM 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 KBKji$xCsEVYe{zi literal 0 HcmV?d00001