Voice Govern your voice agent

Govern your voice agent

Point a LiveKit, Pipecat or OpenAI Agents SDK voice agent at Forgebench and every model call it makes is governed. That covers Transcription of the caller, the LLM turn and Speech Synthesis of the reply. Each call is budget-checked, guardrailed, metered in its own unit and audited. In your code you change only the base_url and api_key.

Voice is available on Pro and Enterprise. On other plans the routes return 402 with code plan_required. Policy, Voice Assets and Consent Records are covered in Voice governance.

What is governed

StepRouteMeasured in
LLM turnPOST /v1/chat/completionstokens
Transcription (batch)POST /v1/audio/transcriptionsaudio seconds
Transcription (streaming)WS /v1/realtime?intent=transcription, WS /v1/listenaudio seconds
Speech SynthesisPOST /v1/audio/speechcharacters

Each call is one Governed Call with one audit entry and one metering event. A Voice Conversation is the Governed Calls that share a session value, so a phone call shows as one conversation in the trace views.

Usage Units and pricing

Voice is priced in the unit the provider bills (its Usage Unit), not converted to tokens.

Governed CallUsage UnitCatalog price shown as
Chat completiontokensper 1K tokens
Transcriptionaudio secondsper minute
Speech Synthesischaractersper 1K characters
Image generationimagesper image

GET /v1/models reports this per model: unit is one of tokens, images, audio_seconds or characters, and unit_price_usd is the price of one second, one character or one image (null for token models, which keep their per-1K prices).

Prices are the provider's cost, with no Forgebench markup. Budgets are in dollars, so a Transcription draws down the same budget as a chat call. The minimum charge for chat calls doesn't apply to audio: a two-second Transcription is charged what it cost. See the per-unit note in the Gateway catalog.

The OpenAI-compatible routes

The routes follow the OpenAI audio API, so any OpenAI client works once you point its base URL at Forgebench.

  • POST /v1/audio/transcriptions: multipart with file, model, and optionally language, prompt, response_format (json, text, verbose_json) and metadata (a JSON string).
  • POST /v1/audio/speech: JSON with model, input, voice, and optionally response_format (mp3, opus, wav, pcm, flac), speed and metadata. The response body is audio; the trace id is in the X-Forgebench-Trace-Id response header.
  • Voice Conversation key: metadata.session if present, otherwise the X-Forgebench-Session request header.
  • Audio longer than 600 seconds is rejected with 413 (the limit is configurable) before the provider is called, so nothing is charged.
  • Checks run in this order: authentication and role (401, 403), plan (402 plan_required), then budget (402 with a budget code).
  • A voice policy refusal is 422 disclosure_missing, or 403 voice_not_allowed / marking_required. See Voice governance.
  • Speech Synthesis audio is buffered: you receive it when synthesis finishes, not as it is produced.
from forgebench import (
  Forgebench, BudgetExceededError, PermissionDeniedError, ValidationError,
)

client = Forgebench(api_key="sk_...", base_url="https://api.forgebench.ai")

try:
  heard = client.audio.transcriptions.create(
      open("caller.wav", "rb"),
      "gpt-4o-transcribe",
      language="en",
      session="call-8841",
  )
  spoken = client.audio.speech.create(
      "gpt-4o-mini-tts",
      "Hi, I'm an AI assistant. How can I help?",
      "alloy",
      response_format="mp3",
      session="call-8841",
  )
  print(heard.text, len(spoken.audio), spoken.trace_id)
except BudgetExceededError as e:
  print("budget:", e.code)
except PermissionDeniedError as e:
  print("blocked:", e.code)  # model_not_allowed, voice_not_allowed, marking_required
except ValidationError as e:
  print("refused:", e.code)  # disclosure_missing

Agents built in Forgebench stream their answer as server-sent events from POST /v1/agents/{id}/run with "stream": true. The console's live talk mode uses this. Send the Voice Conversation key as metadata.session in the body or in the X-Forgebench-Session header.

The agent run stream

The response is text/event-stream with three kinds of frame, in order:

  1. Content deltas, one per model chunk, in the same shape as a chat stream (data: {"object": "chat.completion.chunk", "choices": [{"delta": {"content": "..."}}]}).
  2. One final frame with the whole answer: data: {"type": "agent.run.completed", "answer": "...", "trace_id": "...", "cost_usd": 0.0021, "citations": [...]}. cost_usd is the metered cost of the run and citations are the knowledge chunks that grounded it (an empty list for an ungrounded agent).
  3. data: [DONE].

If the run fails after the first byte, the stream carries an error frame instead of the completed frame and ends without [DONE]:

event: error
data: {"error": {"code": "upstream_error", "message": "..."}}

Refusals that happen before the model is called come back before the first byte, as a normal JSON error with the same body as the non-streaming call:

  • 402: workspace, agent or key budget (for example agent_budget_exceeded)
  • 403: a model the key may not call
  • 404: the agent doesn't exist
  • 400: a guardrail or agent tool binding can't be enforced over a stream

Budget is reserved and the audit entry written before the first byte. Metering settles once, after the stream ends. A run that fails before any output releases its reservation. Without stream, the endpoint returns the cited JSON answer.

try:
  for event in client.agents.run(agent_id, message="Where is my order?", stream=True, session="call-8841"):
      print(event)
except BudgetExceededError:
  print("over budget, nothing was spent")

Point your framework at Forgebench

Create an sk_... key in API keys, then set the base URL on each model client to https://api.forgebench.ai/v1. The framework holds only the Forgebench key, never your provider credentials.

LiveKit Agents

from livekit.plugins import openai

FORGEBENCH = "https://api.forgebench.ai/v1"

session = AgentSession(
  llm=openai.LLM(model="gpt-4o-mini", base_url=FORGEBENCH, api_key="sk_..."),
  tts=openai.TTS(model="gpt-4o-mini-tts", voice="alloy", base_url=FORGEBENCH, api_key="sk_..."),
  # stt=...  see below
)
  • LLM and Speech Synthesis: openai.LLM(base_url=, api_key=) and openai.TTS(base_url=, api_key=) are documented and work as above.
  • Batch Transcription: for Python, LiveKit documents openai.STT with model and language but no base_url, and deepgram.STT(base_url=) isn't documented either. The Node.js plugin documents baseURL and apiKey. Don't assume Python STT is governed: after a test call, check the Audit log for audio_seconds rows.
  • Streaming Transcription: openai.STT(use_realtime=True, base_url=) is not documented for Python (useRealtime and baseURL are documented for Node.js only). The Forgebench endpoint exists (see below); pointing LiveKit's Python plugin at it is unconfirmed.
  • Session header: LiveKit's OpenAI plugin doesn't document a default headers argument. Calls still work and are metered, but aren't grouped into a Voice Conversation unless the request carries a session.

Pipecat

import httpx
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.services.openai.stt import OpenAISTTService
from pipecat.services.openai.tts import OpenAITTSService

FORGEBENCH = "https://api.forgebench.ai/v1"
SESSION = {"X-Forgebench-Session": "call-8841"}

llm = OpenAILLMService(
  api_key="sk_...", model="gpt-4o-mini",
  base_url=FORGEBENCH,
  default_headers=SESSION,
)
stt = OpenAISTTService(
  api_key="sk_...", base_url=FORGEBENCH,
  http_client=httpx.AsyncClient(headers=SESSION),
  settings=OpenAISTTService.Settings(model="gpt-4o-transcribe"),
)
tts = OpenAITTSService(
  api_key="sk_...", base_url=FORGEBENCH,
  http_client=httpx.AsyncClient(headers=SESSION),
  settings=OpenAITTSService.Settings(model="gpt-4o-mini-tts", voice="alloy"),
)

base_url is documented on OpenAILLMService, OpenAISTTService, OpenAITTSService, OpenAIRealtimeSTTService and DeepgramSTTService. default_headers is documented on OpenAILLMService. The STT and TTS services document an http_client argument; the example sets the header on that client. Pipecat doesn't document how that client's headers are used, so confirm with a test call. Pipecat creates services once per process, so a per-call session header means creating the services per call.

For streaming Transcription:

from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.services.openai.stt import OpenAIRealtimeSTTService

# OpenAI realtime-transcription protocol
stt = OpenAIRealtimeSTTService(api_key="sk_...", base_url="wss://api.forgebench.ai/v1/realtime")

# or Deepgram protocol
stt = DeepgramSTTService(api_key="sk_...", base_url="wss://api.forgebench.ai")

Pipecat documents base_url as the WebSocket base URL for OpenAIRealtimeSTTService, and as a custom API base URL for DeepgramSTTService. The path each appends isn't documented, so test the connection and compare the URL it requests with the routes in Streaming Transcription. Pipecat's streaming services send no ?model=, so Forgebench reads the model from the first session.update event. Neither documents a header argument, so streaming connections aren't grouped by session.

OpenAI Agents SDK

from agents.voice import OpenAIVoiceModelProvider

provider = OpenAIVoiceModelProvider(
  base_url="https://api.forgebench.ai/v1",
  api_key="sk_...",
)

OpenAIVoiceModelProvider(base_url=, api_key=) is documented. To attach it to a VoicePipeline, see the Agents SDK voice pipeline docs. The provider uses this client for batch Transcription, Speech Synthesis and the streaming Transcription WebSocket.

To route the agent's LLM turns through Forgebench too, set OPENAI_BASE_URL and OPENAI_API_KEY to the same values, or use a custom chat model client as the Agents SDK docs describe.

The provider takes either base_url and api_key, or an openai_client, not both. The SDK doesn't document default request headers, so pass metadata.session on the calls you make yourself, or group by agent instead.

Setting the session

FrameworkDefault session header
Pipecat LLMYes, default_headers (documented)
Pipecat STT and TTSVia http_client, verify with a test call
Pipecat streaming TranscriptionNot documented
LiveKit AgentsNot documented
OpenAI Agents SDKNot documented
Forgebench SDKsYes, the session argument on every call

Where you can't set a header, send metadata.session in the body of speech, Transcription and chat requests. If a request has both, metadata.session wins.

Streaming Transcription

Streaming Transcription is a WebSocket that speaks the provider's own protocol, so existing clients connect unchanged.

ProtocolURL
OpenAI realtime-transcriptionwss://api.forgebench.ai/v1/realtime?intent=transcription
Deepgramwss://api.forgebench.ai/v1/listen

Any other intent on /v1/realtime is closed with code 1008. Speech-to-speech Realtime Connections aren't supported yet.

Model. Taken from ?model= if present, otherwise from the first session.update event, otherwise the agent's or key's default.

Authentication. Any one of:

  • Authorization: Bearer sk_... (OpenAI clients) or Authorization: Token sk_... (Deepgram clients)
  • an api-key header
  • a WebSocket subprotocol: openai-insecure-api-key.sk_... for OpenAI-protocol clients, or token, sk_... for Deepgram
  • the console session cookie, for the console itself

Budget. A connection is one Governed Call. Forgebench reserves budget in blocks of 60 audio seconds and renews as audio flows. When the budget can't cover another block, the current utterance may finish (up to 15 more seconds), then the connection closes with a budget error event. At close you're charged for the audio relayed, using the provider's reported duration when available, as one metering event in audio seconds.

Streaming connections go straight to OpenAI or Deepgram, not through the model gateway, so they don't get gateway fallbacks or retries. Gemini and Vertex streaming Transcription aren't available yet.

Next

Cookie settings

Our Cookie Policy lists every cookie and what it does.