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
| Step | Route | Measured in |
|---|---|---|
| LLM turn | POST /v1/chat/completions | tokens |
| Transcription (batch) | POST /v1/audio/transcriptions | audio seconds |
| Transcription (streaming) | WS /v1/realtime?intent=transcription, WS /v1/listen | audio seconds |
| Speech Synthesis | POST /v1/audio/speech | characters |
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 Call | Usage Unit | Catalog price shown as |
|---|---|---|
| Chat completion | tokens | per 1K tokens |
| Transcription | audio seconds | per minute |
| Speech Synthesis | characters | per 1K characters |
| Image generation | images | per 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 withfile,model, and optionallylanguage,prompt,response_format(json,text,verbose_json) andmetadata(a JSON string).POST /v1/audio/speech: JSON withmodel,input,voice, and optionallyresponse_format(mp3,opus,wav,pcm,flac),speedandmetadata. The response body is audio; the trace id is in theX-Forgebench-Trace-Idresponse header.- Voice Conversation key:
metadata.sessionif present, otherwise theX-Forgebench-Sessionrequest 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 (402plan_required), then budget (402with a budget code). - A voice policy refusal is
422disclosure_missing, or403voice_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_missingimport { readFile } from "node:fs/promises";
import {
Forgebench,
BudgetExceededError,
PermissionDeniedError,
UnprocessableEntityError,
} from "@seedlinglabs/forgebench-sdk";
const forgebench = new Forgebench({ baseUrl: "https://api.forgebench.ai", apiKey: "sk_..." });
try {
const heard = await forgebench.audio.transcriptions.create({
file: { data: await readFile("caller.wav"), filename: "caller.wav", contentType: "audio/wav" },
model: "gpt-4o-transcribe",
language: "en",
session: "call-8841",
});
const spoken = await forgebench.audio.speech.create({
model: "gpt-4o-mini-tts",
input: "Hi, I'm an AI assistant. How can I help?",
voice: "alloy",
response_format: "mp3",
session: "call-8841",
});
console.log(heard.text, spoken.audio.byteLength, spoken.traceId);
} catch (e) {
if (e instanceof BudgetExceededError) console.log("budget:", e.code);
else if (e instanceof PermissionDeniedError) console.log("blocked:", e.code);
else if (e instanceof UnprocessableEntityError) console.log("refused:", e.code);
else throw e;
}import ai.forgebench.Forgebench;
import ai.forgebench.SpeechParams;
import ai.forgebench.TranscriptionParams;
import ai.forgebench.errors.BudgetExceededException;
import ai.forgebench.errors.PermissionDeniedException;
import ai.forgebench.errors.ValidationException;
import ai.forgebench.types.SpeechAudio;
import ai.forgebench.types.Transcription;
import java.nio.file.Path;
Forgebench client = Forgebench.builder()
.apiKey("sk_...")
.baseUrl("https://api.forgebench.ai")
.build();
try {
Transcription heard = client.audio().transcriptions().create(TranscriptionParams.builder()
.file(Path.of("caller.wav"))
.model("gpt-4o-transcribe")
.language("en")
.session("call-8841")
.build());
SpeechAudio spoken = client.audio().speech().create(SpeechParams.builder()
.model("gpt-4o-mini-tts")
.input("Hi, I'm an AI assistant. How can I help?")
.voice("alloy")
.responseFormat("mp3")
.session("call-8841")
.build());
System.out.println(heard.getText() + " " + spoken.getAudio().length + " " + spoken.getTraceId());
} catch (BudgetExceededException e) {
System.out.println("budget: " + e.getCode());
} catch (PermissionDeniedException e) {
System.out.println("blocked: " + e.getCode()); // model_not_allowed, voice_not_allowed, marking_required
} catch (ValidationException e) {
System.out.println("refused: " + e.getCode()); // disclosure_missing
}# Batch Transcription
curl -X POST https://api.forgebench.ai/v1/audio/transcriptions \
-H "Authorization: Bearer $FORGEBENCH_API_KEY" \
-H "X-Forgebench-Session: call-8841" \
-F file=@caller.wav \
-F model=gpt-4o-transcribe \
-F language=en
# Speech Synthesis: the audio goes to reply.mp3; -D - prints the response
# headers, including X-Forgebench-Trace-Id
curl -X POST https://api.forgebench.ai/v1/audio/speech \
-H "Authorization: Bearer $FORGEBENCH_API_KEY" \
-H "X-Forgebench-Session: call-8841" \
-H "Content-Type: application/json" \
-D - -o reply.mp3 \
-d @- <<'EOF'
{
"model": "gpt-4o-mini-tts",
"input": "Hi, I'm an AI assistant. How can I help?",
"voice": "alloy",
"response_format": "mp3"
}
EOFAgents 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:
- Content deltas, one per model chunk, in the same shape as a chat stream
(
data: {"object": "chat.completion.chunk", "choices": [{"delta": {"content": "..."}}]}). - One final frame with the whole answer:
data: {"type": "agent.run.completed", "answer": "...", "trace_id": "...", "cost_usd": 0.0021, "citations": [...]}.cost_usdis the metered cost of the run andcitationsare the knowledge chunks that grounded it (an empty list for an ungrounded agent). 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 exampleagent_budget_exceeded)403: a model the key may not call404: the agent doesn't exist400: 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")try {
for await (const event of forgebench.agents.run(agentId, {
message: "Where is my order?",
stream: true,
session: "call-8841",
})) {
console.log(event);
}
} catch (e) {
if (e instanceof BudgetExceededError) console.log("over budget, nothing was spent");
else throw e;
}import ai.forgebench.AgentRunStream;
import ai.forgebench.MessageRunParams;
import ai.forgebench.errors.BudgetExceededException;
import com.fasterxml.jackson.databind.JsonNode;
MessageRunParams params = MessageRunParams.builder("Where is my order?")
.session("call-8841")
.build();
try (AgentRunStream events = client.agents().runStream(agentId, params)) {
for (JsonNode event : events) {
System.out.println(event);
}
} catch (BudgetExceededException e) {
System.out.println("over budget, nothing was spent");
}# -N prints each event as it arrives; a 402 comes back as JSON before the stream opens
curl -N -X POST https://api.forgebench.ai/v1/agents/$AGENT_ID/run \
-H "Authorization: Bearer $FORGEBENCH_API_KEY" \
-H "X-Forgebench-Session: call-8841" \
-H "Content-Type: application/json" \
-d '{"message": "Where is my order?", "stream": true}'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=)andopenai.TTS(base_url=, api_key=)are documented and work as above. - Batch Transcription: for Python, LiveKit documents
openai.STTwithmodelandlanguagebut nobase_url, anddeepgram.STT(base_url=)isn't documented either. The Node.js plugin documentsbaseURLandapiKey. Don't assume Python STT is governed: after a test call, check the Audit log foraudio_secondsrows. - Streaming Transcription:
openai.STT(use_realtime=True, base_url=)is not documented for Python (useRealtimeandbaseURLare 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
| Framework | Default session header |
|---|---|
| Pipecat LLM | Yes, default_headers (documented) |
| Pipecat STT and TTS | Via http_client, verify with a test call |
| Pipecat streaming Transcription | Not documented |
| LiveKit Agents | Not documented |
| OpenAI Agents SDK | Not documented |
| Forgebench SDKs | Yes, 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.
| Protocol | URL |
|---|---|
| OpenAI realtime-transcription | wss://api.forgebench.ai/v1/realtime?intent=transcription |
| Deepgram | wss://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) orAuthorization: Token sk_...(Deepgram clients)- an
api-keyheader - a WebSocket subprotocol:
openai-insecure-api-key.sk_...for OpenAI-protocol clients, ortoken, 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.

