diff --git a/CHANGELOG.md b/CHANGELOG.md index e33491773ffe..e0b544adfbf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai ### Fixes - **Gateway in-process restarts:** clear stale SIGUSR1 restart state and resume prepared host suspensions before rebuilding runtime admission, preventing restart cooldowns or paused scheduling from leaking into the next lifecycle. +- **Deepgram realtime custom endpoints:** validate Voice Call streaming base URLs with secret-safe errors, preserve explicit `ws://` and `wss://` endpoints, and map HTTP schemes to their matching WebSocket transport for dedicated and self-hosted deployments. (#105334) Thanks @dwc1997. - **macOS remote node readiness:** take the main-session key from the node hello snapshot instead of opening an operator connection during node admission, preventing remote tunnel recovery from leaving Computer Use and node exec stuck in lifecycle transition. - **Claude CLI context budgets:** honor Anthropic model and per-agent `contextTokens` limits by passing the effective limit to Claude Code's native auto-compactor and persisting the same prepared budget in OpenClaw session state. Fixes #80933. (#93198) Thanks @mushuiyu886. - **Native app connection and relay reliability:** keep Android disconnects stopped across Activity recreation, fail remote camera commands without opening permission prompts, refresh mobile node registration after capability changes, surface iOS onboarding connection failures, cancel stale Talk owners on session switches, reject invalid Watch acknowledgments, preserve Watch events received during startup, and prevent older agent overview requests from replacing newer gateway state. diff --git a/docs/providers/deepgram.md b/docs/providers/deepgram.md index 5a043e34ebc7..8cf35279e919 100644 --- a/docs/providers/deepgram.md +++ b/docs/providers/deepgram.md @@ -106,15 +106,16 @@ Deepgram `/listen` request, so any Deepgram-supported param name works The bundled `deepgram` plugin also registers a realtime transcription provider for the Voice Call plugin. -| Setting | Config path | Default | -| --------------- | ----------------------------------------------------------------------- | -------------------------------- | -| API key | `plugins.entries.voice-call.config.streaming.providers.deepgram.apiKey` | Falls back to `DEEPGRAM_API_KEY` | -| Model | `...deepgram.model` | `nova-3` | -| Language | `...deepgram.language` | (unset) | -| Encoding | `...deepgram.encoding` | `mulaw` | -| Sample rate | `...deepgram.sampleRate` | `8000` | -| Endpointing | `...deepgram.endpointingMs` | `800` | -| Interim results | `...deepgram.interimResults` | `true` | +| Setting | Config path | Default | +| --------------- | ----------------------------------------------------------------------- | -------------------------------------------- | +| API key | `plugins.entries.voice-call.config.streaming.providers.deepgram.apiKey` | Falls back to `DEEPGRAM_API_KEY` | +| Base URL | `...deepgram.baseUrl` | `DEEPGRAM_BASE_URL` or Deepgram's public API | +| Model | `...deepgram.model` | `nova-3` | +| Language | `...deepgram.language` | (unset) | +| Encoding | `...deepgram.encoding` | `mulaw` | +| Sample rate | `...deepgram.sampleRate` | `8000` | +| Endpointing | `...deepgram.endpointingMs` | `800` | +| Interim results | `...deepgram.interimResults` | `true` | ```json5 { @@ -141,6 +142,12 @@ for the Voice Call plugin. } ``` +For a [Deepgram custom endpoint](https://developers.deepgram.com/reference/custom-endpoints), +set `baseUrl` to the endpoint root, including any base path but not `/listen`. +Realtime endpoints accept `http://`, `https://`, `ws://`, and `wss://`. HTTP +maps to WS, HTTPS maps to WSS, and explicit WebSocket schemes stay unchanged. +Malformed URLs and other schemes fail during session setup. + Voice Call receives telephony audio as 8 kHz G.711 u-law. The Deepgram streaming provider defaults to `encoding: "mulaw"` and `sampleRate: 8000`, so diff --git a/extensions/deepgram/realtime-transcription-provider.test.ts b/extensions/deepgram/realtime-transcription-provider.test.ts index 5ef93b28cbc5..c50f7a552638 100644 --- a/extensions/deepgram/realtime-transcription-provider.test.ts +++ b/extensions/deepgram/realtime-transcription-provider.test.ts @@ -1,13 +1,69 @@ // Deepgram tests cover realtime transcription provider plugin behavior. +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type WebSocket from "ws"; +import { WebSocketServer } from "ws"; import { testing, buildDeepgramRealtimeTranscriptionProvider, } from "./realtime-transcription-provider.js"; +let cleanup: (() => Promise) | undefined; + +async function createDeepgramRealtimeServer(params: { + onRequest: (url: URL, headers: Record) => void; +}) { + const server = createServer(); + const wss = new WebSocketServer({ noServer: true }); + const clients = new Set(); + + server.on("upgrade", (request, socket, head) => { + params.onRequest(new URL(request.url ?? "/", "http://127.0.0.1"), request.headers); + wss.handleUpgrade(request, socket, head, (ws) => { + clients.add(ws); + ws.on("close", () => clients.delete(ws)); + }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + const port = (server.address() as AddressInfo).port; + cleanup = async () => { + for (const ws of clients) { + ws.terminate(); + } + await new Promise((resolve) => { + wss.close(() => resolve()); + }); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }; + return { baseUrl: `http://127.0.0.1:${port}/deepgram/v1` }; +} + +function buildTestRealtimeUrl(baseUrl: string): URL { + return new URL( + testing.toDeepgramRealtimeWsUrl({ + apiKey: "dg-key", + baseUrl, + model: "nova-3", + providerConfig: {}, + sampleRate: 8000, + encoding: "mulaw", + interimResults: true, + endpointingMs: 800, + }), + ); +} + describe("buildDeepgramRealtimeTranscriptionProvider", () => { - afterEach(() => { + afterEach(async () => { + await cleanup?.(); + cleanup = undefined; vi.unstubAllEnvs(); }); @@ -67,4 +123,72 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => { "Deepgram API key missing", ); }); + + it("returns the default when no value or env is set", () => { + vi.stubEnv("DEEPGRAM_BASE_URL", ""); + expect(testing.normalizeDeepgramRealtimeBaseUrl(undefined)).toBe("https://api.deepgram.com/v1"); + expect(testing.normalizeDeepgramRealtimeBaseUrl(" ")).toBe("https://api.deepgram.com/v1"); + }); + + it.each([ + ["http://localhost:8080/deepgram/v1", "ws:"], + ["https://custom.example.com/deepgram/v1", "wss:"], + ["ws://localhost:8080/deepgram/v1", "ws:"], + ["wss://custom.example.com:8443/deepgram/v1", "wss:"], + ])("maps or preserves %s as %s", (baseUrl, expectedProtocol) => { + const url = buildTestRealtimeUrl(baseUrl); + expect(url.protocol).toBe(expectedProtocol); + expect(url.pathname).toBe("/deepgram/v1/listen"); + }); + + it.each(["not a url", "ftp://files.example.com"])("rejects invalid endpoint %s", (baseUrl) => { + expect(() => testing.normalizeDeepgramRealtimeBaseUrl(baseUrl)).toThrow( + /^Invalid Deepgram baseUrl:/, + ); + }); + + it("validates the environment override", () => { + vi.stubEnv("DEEPGRAM_BASE_URL", "not a url"); + expect(() => testing.normalizeDeepgramRealtimeBaseUrl()).toThrow( + "Invalid Deepgram baseUrl: value is not a valid URL", + ); + }); + + it("does not echo the configured URL in validation errors", () => { + const rawMarker = "configured-value-marker"; + const nonHttp = `ftp://files.example.com/${rawMarker}`; + try { + testing.normalizeDeepgramRealtimeBaseUrl(nonHttp); + throw new Error("expected rejection"); + } catch (error) { + const message = (error as Error).message; + expect(message).toMatch(/unsupported scheme/); + expect(message).not.toContain(rawMarker); + } + }); + + it("connects through an explicit HTTP base URL over loopback WebSocket", async () => { + const requests: Array<{ + url: URL; + headers: Record; + }> = []; + const server = await createDeepgramRealtimeServer({ + onRequest: (url, headers) => requests.push({ url, headers }), + }); + const provider = buildDeepgramRealtimeTranscriptionProvider(); + const session = provider.createSession({ + providerConfig: { + apiKey: "dummy", + baseUrl: server.baseUrl, + }, + }); + + await session.connect(); + session.close(); + + expect(requests).toHaveLength(1); + expect(requests[0]?.url.pathname).toBe("/deepgram/v1/listen"); + expect(requests[0]?.url.searchParams.get("model")).toBe("nova-3"); + expect(requests[0]?.headers.authorization).toBe("Token dummy"); + }); }); diff --git a/extensions/deepgram/realtime-transcription-provider.ts b/extensions/deepgram/realtime-transcription-provider.ts index 037b5d4b509d..65d01dc72ab0 100644 --- a/extensions/deepgram/realtime-transcription-provider.ts +++ b/extensions/deepgram/realtime-transcription-provider.ts @@ -90,15 +90,36 @@ function normalizeDeepgramEncoding( } function normalizeDeepgramRealtimeBaseUrl(value?: string): string { - return ( - normalizeOptionalString(value ?? process.env.DEEPGRAM_BASE_URL) ?? - DEFAULT_DEEPGRAM_AUDIO_BASE_URL - ); + const resolved = normalizeOptionalString(value ?? process.env.DEEPGRAM_BASE_URL); + if (!resolved) { + return DEFAULT_DEEPGRAM_AUDIO_BASE_URL; + } + let parsed: URL; + try { + parsed = new URL(resolved); + } catch { + throw new Error("Invalid Deepgram baseUrl: value is not a valid URL"); + } + const { protocol } = parsed; + if (protocol !== "http:" && protocol !== "https:" && protocol !== "ws:" && protocol !== "wss:") { + // Endpoint URLs can contain userinfo or sensitive query values. Keep the + // error actionable without echoing the configured value. + throw new Error( + `Invalid Deepgram baseUrl: unsupported scheme "${protocol}" (expected http, https, ws, or wss)`, + ); + } + return resolved; } function toDeepgramRealtimeWsUrl(config: DeepgramRealtimeTranscriptionSessionConfig): string { const url = new URL(normalizeDeepgramRealtimeBaseUrl(config.baseUrl)); - url.protocol = url.protocol === "http:" ? "ws:" : "wss:"; + // Self-hosted Deepgram may explicitly use ws:// without TLS. Translate only + // matching HTTP schemes so direct WebSocket endpoints keep their contract. + if (url.protocol === "http:") { + url.protocol = "ws:"; + } else if (url.protocol === "https:") { + url.protocol = "wss:"; + } url.pathname = `${url.pathname.replace(/\/+$/, "")}/listen`; url.searchParams.set("model", config.model); url.searchParams.set("encoding", config.encoding); @@ -250,6 +271,7 @@ export function buildDeepgramRealtimeTranscriptionProvider(): RealtimeTranscript export const testing = { normalizeProviderConfig, + normalizeDeepgramRealtimeBaseUrl, toDeepgramRealtimeWsUrl, }; export { testing as __testing };