π§ͺ Automated Model Benchmarking Pipeline SuperAdmin A/B Testing for Voice Agent Models β
Priority: High | Type: Feature | Estimated Effort: 8-12 days β
Problem β
We manually test different STT/TTS/LLM model combinations by changing agent config, making a call, listening to it, and judging quality ourselves. This is:
- Slow testing 5 STT Γ 5 TTS Γ 3 LLM = 75 combinations manually is impractical
- Subjective no consistent scoring across tests
- Not tracked no historical data to compare cost vs quality vs latency across combinations
- Not repeatable can't re-run the same test scenario to compare fairly
We need an automated benchmarking pipeline that the SuperAdmin can run from the UI to systematically test model combinations on real phone calls, collect metrics, and rank them.
How Competitors / Industry Does This β
Before building, understand how the voice AI industry approaches model evaluation:
1. A/B Testing on Live Traffic (Vapi, Bland.ai, Retell) β
Platforms like Vapi route a percentage of live calls through different model configs and compare metrics. We can't do this yet (too risky for customer campaigns), but the benchmark pipeline is the first step toward it.
2. Synthetic Evaluation (Hamming.ai, Vocode) β
They call a test phone number where another AI answers (simulating a real person), then evaluate the conversation. This removes the need for a real human to pick up every test call. We should support this.
3. LLM-as-Judge (Industry Standard) β
After a call, an LLM (GPT-4o, Claude) scores the conversation on multiple dimensions: naturalness, task completion, latency perception, Hindi accuracy, etc. This is how OpenAI, Google, and Anthropic evaluate their own models. We already have an Analysis pipeline we extend it.
4. Metric Collection (Everyone) β
Every serious platform tracks: Time-to-First-Token (TTFT), end-to-end latency, STT word error rate, TTS naturalness score, LLM response relevance, cost per minute, and call completion rate.
Our Approach: Combine All Four β
We build a "Benchmark Campaign" that the SuperAdmin creates from their UI. It's like a regular campaign but:
- It tests multiple model combinations (a "test matrix")
- Each combination calls the same phone number(s) with the same prompt
- After all calls complete, an LLM judge scores every conversation
- Results are stored in a dedicated collection and displayed in a comparison dashboard
Architecture Overview β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β SuperAdmin UI β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Benchmark Creator β β
β β - Test phone numbers (real or synthetic) β β
β β - Agent prompt + context β β
β β - Model matrix (STT Γ TTS Γ LLM combinations) β β
β β - Evaluation criteria β β
β β - [Start Benchmark] β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Benchmark Results Dashboard β β
β β - Comparison table: latency, cost, quality scores β β
β β - Listen to recordings side-by-side β β
β β - LLM judge scores per dimension β β
β β - Winner recommendation β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Backend (client_backend) β
β β
β POST /api/superadmin/benchmarks β Create benchmark β
β POST /api/superadmin/benchmarks/:id/run β Start execution β
β GET /api/superadmin/benchmarks β List benchmarks β
β GET /api/superadmin/benchmarks/:id β Get results β
β POST /api/superadmin/benchmarks/:id/judge β Trigger LLM evalβ
β β
β BenchmarkService: β
β - Expands model matrix into individual test runs β
β - Creates temporary Agent docs per combination β
β - Dispatches calls sequentially (one combo at a time) β
β - Collects metrics from Langfuse + webhooks β
β - Triggers LLM judge after all calls complete β
ββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β
ββββββββββββββΌβββββββββββββββββ
βΌ βΌ βΌ
ββββββββββββββββ ββββββββββ ββββββββββββββββββββ
β LiveKit Agent β βLangfuseβ β Analysis Service β
β (uses temp β β(metricsβ β (LLM Judge mode) β
β agent config)β β traces)β β β
ββββββββββββββββ ββββββββββ ββββββββββββββββββββData Model β
New Collection: Benchmark β
interface IBenchmark {
benchmarkUID: string; // unique ID (nanoid)
name: string; // "Hindi TTS Comparison March 2026"
status: "draft" | "running" | "completed" | "failed";
createdBy: string; // superadmin userUID
// Test Configuration
testConfig: {
phoneNumbers: string[]; // numbers to call (1-5 numbers)
prompt: string; // agent system prompt
context: string; // agent context
firstMessage: string; // what agent says first
maxCallDuration: number; // auto-hangup (seconds), default 60
callsPerCombination: number; // how many times to call each combo (1-3 for statistical significance)
language: string; // "hi", "en"
};
// Model Matrix each array item is tested in combination
modelMatrix: {
stt: Array<{ provider: string; model: string; label?: string }>;
tts: Array<{ provider: string; model: string; voice: string; label?: string }>;
llm: Array<{ provider: string; model: string; temperature?: number; label?: string }>;
};
// Evaluation Config
evaluation: {
useLLMJudge: boolean;
judgeModel: string; // "gpt-4o" or "gemini-2.5-flash"
criteria: string[]; // ["naturalness", "hindi_accuracy", "task_completion", "response_relevance", "latency_perception"]
customCriteria?: string; // free-text additional evaluation instructions
};
// Computed
totalCombinations: number; // stt.length Γ tts.length Γ llm.length
totalCalls: number; // totalCombinations Γ callsPerCombination Γ phoneNumbers.length
// Results (populated after completion)
summary?: {
winner: {
stt: { provider: string; model: string };
tts: { provider: string; model: string };
llm: { provider: string; model: string };
};
rankedCombinations: Array<{
rank: number;
combinationId: string;
overallScore: number;
costPerMin: number;
avgLatency: number;
}>;
};
startedAt?: Date;
completedAt?: Date;
createdAt: Date;
updatedAt: Date;
}New Collection: BenchmarkRun β
Each individual call in the benchmark:
interface IBenchmarkRun {
runUID: string; // unique ID
benchmarkUID: string; // parent benchmark
combinationId: string; // hash of stt+tts+llm combo for grouping
combinationIndex: number; // 0, 1, 2... for ordering
callIndex: number; // which repeat (0, 1, 2 if callsPerCombination=3)
// Model Config for this run
models: {
stt: { provider: string; model: string };
tts: { provider: string; model: string; voice: string };
llm: { provider: string; model: string; temperature?: number };
};
// Call Data (populated during/after call)
phoneNumber: string;
contactUID: string; // links to Contact doc
roomId: string;
status: "pending" | "calling" | "completed" | "failed";
// Metrics (populated from Langfuse + webhooks)
metrics: {
callDuration?: number; // seconds
billingDuration?: number; // seconds (for cost calc)
// Latency metrics (from Langfuse OTel spans)
sttLatency?: number; // ms time from audio to transcript
llmTimeToFirstToken?: number; // ms TTFT
llmTotalLatency?: number; // ms full response
ttsLatency?: number; // ms time from text to first audio
endToEndLatency?: number; // ms user stops speaking β agent starts speaking
// Usage metrics
llmPromptTokens?: number;
llmCompletionTokens?: number;
ttsCharacters?: number;
sttAudioDuration?: number; // seconds
// Cost (calculated from provider pricing)
estimatedCost?: number; // USD
costPerMin?: number; // USD/min
};
// Transcription (from post-call sync)
transcription?: any;
recordingUrl?: string;
// LLM Judge Scores (populated after judge runs)
judgeScores?: {
overall: number; // 1-10
dimensions: Record<string, number>; // { naturalness: 8, hindi_accuracy: 7, ... }
reasoning: string; // judge's explanation
judgeModel: string; // which model judged
};
startedAt?: Date;
completedAt?: Date;
error?: string;
}Implementation Plan β
Phase 1: Backend Benchmark CRUD & Execution Engine (4-5 days) β
1.1 Models & Routes β
Create:
client_backend/src/models/Benchmark.tsMongoose schema forIBenchmarkclient_backend/src/models/BenchmarkRun.tsMongoose schema forIBenchmarkRunclient_backend/src/routes/benchmark.routes.tsSuperAdmin-only routesclient_backend/src/controllers/benchmark.controller.tsclient_backend/src/services/benchmark.service.tsCore orchestration logic
Routes (all behind requireRole("superadmin")):
POST /api/superadmin/benchmarks β createBenchmark
GET /api/superadmin/benchmarks β listBenchmarks
GET /api/superadmin/benchmarks/:id β getBenchmark (with runs)
POST /api/superadmin/benchmarks/:id/start β startBenchmark
POST /api/superadmin/benchmarks/:id/stop β stopBenchmark
POST /api/superadmin/benchmarks/:id/judge β triggerLLMJudge
DELETE /api/superadmin/benchmarks/:id β deleteBenchmark1.2 Benchmark Execution Service β
This is the core. It:
Expands the matrix Given
stt: [A, B],tts: [X, Y],llm: [P, Q], generates 8 combinations:(A,X,P), (A,X,Q), (A,Y,P), (A,Y,Q), (B,X,P), (B,X,Q), (B,Y,P), (B,Y,Q)Creates temporary Agent docs For each combination, creates a temporary Agent document with the specific STT/TTS/LLM config. These are marked with
isBenchmark: trueso they don't pollute the real agent list.Runs calls sequentially Unlike regular campaigns that run concurrently, benchmark calls run one combination at a time (or one at a time per phone number). This ensures fair comparison you don't want network congestion from parallel calls affecting latency measurements.
Reuses existing infrastructure Each benchmark call goes through the same
createDispatch()β LiveKit Agent β SIP call β webhook pipeline. The Contact documents are created withisTestCall: true(this field already exists in your Contact model).Collects metrics After each call completes (webhook fires), the service pulls metrics from:
- Contact document (callDuration, billingDuration, transcription)
- Langfuse API (latency spans STT/LLM/TTS timing)
- Calculates cost based on provider pricing
Triggers LLM judge After ALL calls complete, sends each transcription to the judge LLM.
Key design decision: Run calls sequentially, not in parallel. Parallel calls would share network/CPU resources and make latency comparisons unfair. Sequential execution is slower but produces reliable data.
// Pseudocode for the execution loop
async function executeBenchmark(benchmarkUID: string) {
const benchmark = await Benchmark.findOne({ benchmarkUID });
const combinations = expandMatrix(benchmark.modelMatrix);
for (const combo of combinations) {
for (let callIdx = 0; callIdx < benchmark.testConfig.callsPerCombination; callIdx++) {
for (const phone of benchmark.testConfig.phoneNumbers) {
// 1. Create temp agent with this combo's config
const tempAgent = await createTempAgent(benchmark, combo);
// 2. Create Contact doc (isTestCall: true)
const contact = await createBenchmarkContact(benchmark, phone, tempAgent);
// 3. Create BenchmarkRun doc
const run = await BenchmarkRun.create({ ... });
// 4. Dispatch call (reuse existing infrastructure)
await createDispatch({
mode: "call",
phone_number: phone,
agent_id: tempAgent.agent_id,
// ... standard dispatch metadata
});
// 5. Wait for call to complete (poll Contact status or use webhook callback)
await waitForCallCompletion(contact.contactUID, benchmark.testConfig.maxCallDuration + 30);
// 6. Collect metrics
await collectRunMetrics(run.runUID, contact.contactUID);
}
}
}
// 7. Run LLM judge on all completed runs
if (benchmark.evaluation.useLLMJudge) {
await runLLMJudge(benchmarkUID);
}
// 8. Compute rankings and summary
await computeBenchmarkSummary(benchmarkUID);
}1.3 Waiting for Call Completion β
The benchmark service needs to know when a call finishes. Two approaches:
Option A (Recommended): Polling Poll the Contact document every 5 seconds until status changes from in_progress to done or failed. Simple, reliable, works with existing webhook pipeline.
async function waitForCallCompletion(contactUID: string, timeoutSec: number): Promise<void> {
const deadline = Date.now() + timeoutSec * 1000;
while (Date.now() < deadline) {
const contact = await Contact.findOne({ contactUID }).lean();
if (contact?.status === "done" || contact?.status === "failed") return;
await sleep(5000);
}
throw new Error(`Call timed out after ${timeoutSec}s`);
}Option B: Redis Pub/Sub Webhook handler publishes to a Redis channel when a benchmark call completes. More efficient but more complex. Use Option A first, optimize later if needed.
1.4 Metric Collection from Langfuse β
After a call completes, pull latency metrics from Langfuse using their API:
async function collectLatencyMetrics(roomName: string): Promise<LatencyMetrics> {
// Langfuse API: GET /api/public/traces?sessionId={roomName}
// Each trace contains spans for STT, LLM, TTS with duration_ms
const traces = await langfuseClient.getTraces({ sessionId: roomName });
// Extract timing from OTel spans
// LiveKit auto-emits spans named: "stt.recognize", "llm.chat", "tts.synthesize"
// Each span has start_time, end_time, duration
}Alternatively, extend the existing post_call.py to write latency metrics directly to the Contact or BenchmarkRun document. This avoids an extra Langfuse API call.
Phase 2: LLM Judge (2 days) β
2.1 Judge Prompt Design β
The LLM judge evaluates each conversation transcript on configurable dimensions. This is a well-established technique in AI evaluation.
const JUDGE_SYSTEM_PROMPT = `You are an expert evaluator of AI voice agent conversations.
You will be given a transcript of a phone call between an AI agent and a human.
Score the conversation on each dimension from 1-10 and provide brief reasoning.
Evaluation dimensions:
{{dimensions}}
Additional context:
- The agent's intended behavior: {{agentPrompt}}
- Language: {{language}}
Respond in JSON format:
{
"overall": <1-10>,
"dimensions": { "<dimension_name>": <1-10>, ... },
"reasoning": "<2-3 sentence explanation>"
}`;Default evaluation dimensions:
naturalnessDoes the agent sound like a real person? Natural flow, no robotic patterns?hindi_accuracyFor Hindi calls: correct grammar, natural Hinglish mixing, proper pronunciation cues in transcript?task_completionDid the agent accomplish its goal (as defined in the prompt)?response_relevanceAre responses on-topic and helpful?latency_perceptionBased on transcript timing markers, did the conversation feel responsive?conversation_flowSmooth turn-taking? No awkward interruptions or long silences?
2.2 Implementation β
Extend the existing Analysis service or create a new endpoint:
# Analysis/src/pipeline/benchmark_judge.py
class BenchmarkJudge:
async def evaluate(self, transcription: dict, agent_prompt: str,
criteria: list[str], language: str) -> dict:
# Build judge prompt with criteria
# Call OpenAI/Gemini structured output
# Return scores + reasoningThe judge runs once per BenchmarkRun after all calls complete. Results are stored in BenchmarkRun.judgeScores.
Phase 3: Frontend SuperAdmin Benchmark UI (2-3 days) β
3.1 Benchmark Creator Page β
Location: oktivo_client/src/pages/superadmin/BenchmarkCreator.jsx
Form sections:
- Basic Info: Benchmark name
- Test Setup: Phone numbers (input field, comma-separated), calls per combination (1-3), max call duration, language
- Agent Config: Prompt textarea, context textarea, first message
- Model Matrix:
- STT section: Add/remove rows, each row = provider dropdown + model input
- TTS section: Add/remove rows, each row = provider dropdown + model input + voice input
- LLM section: Add/remove rows, each row = provider dropdown + model input + temperature slider
- Show "Total combinations: X, Total calls: Y" live counter
- Evaluation: Toggle LLM judge on/off, select judge model, checkboxes for evaluation criteria
- Preview & Start: Show matrix summary, estimated time, start button
3.2 Benchmark Results Page β
Location: oktivo_client/src/pages/superadmin/BenchmarkResults.jsx
Sections:
- Status bar: Running/Completed, progress (X/Y calls done)
- Comparison Table: Rows = combinations, Columns = avg latency, cost/min, judge scores per dimension, overall score. Sortable by any column. Highlight the winner row.
- Detail View (click a row): Show individual call recordings, transcriptions, per-call metrics, judge reasoning
- Side-by-side Player: Select 2 combinations, play their recordings simultaneously to hear the difference
3.3 Navigation β
Add to superadmin sidebar:
- "Benchmarks" menu item β lists all benchmarks
- "New Benchmark" button β opens creator
Cost Estimation & Pricing β
The benchmark system needs to estimate costs before running. Build a simple pricing table:
// client_backend/src/config/modelPricing.ts
const MODEL_PRICING = {
stt: {
"deepgram/nova-3": { perMinute: 0.0043 },
"openai/whisper-large-v3": { perMinute: 0.006 },
"assemblyai/universal-3": { perMinute: 0.01 },
"huggingface/*": { perMinute: 0.0 }, // free tier
},
tts: {
"cartesia/sonic-3": { per1kChars: 0.06 },
"elevenlabs/eleven_turbo_v2_5": { per1kChars: 0.18 },
"deepgram/aura-2": { per1kChars: 0.015 },
"huggingface/*": { per1kChars: 0.0 },
},
llm: {
"google/gemini-2.5-flash": { perMillionInputTokens: 0.15, perMillionOutputTokens: 0.60 },
"openai/gpt-4o-mini": { perMillionInputTokens: 0.15, perMillionOutputTokens: 0.60 },
"huggingface/*": { perMillionInputTokens: 0.0, perMillionOutputTokens: 0.0 },
"groq/llama-3.3-70b": { perMillionInputTokens: 0.0, perMillionOutputTokens: 0.0 },
}
};Show estimated cost before starting: "This benchmark will make ~24 calls, estimated cost: $X.XX"
What NOT to Build (Keep Scope Tight) β
- β Don't build synthetic callee (AI answering the phone) use real test numbers for now. This is a future enhancement.
- β Don't build automatic A/B routing on live campaigns that's a separate feature.
- β Don't build a custom Langfuse dashboard use the existing Langfuse UI for deep metric analysis. Our UI just shows the summary.
- β Don't modify the existing campaign system benchmarks are a separate flow that reuses the same underlying call infrastructure.
- β Don't build real-time streaming of benchmark progress simple polling (refresh page or auto-refresh every 10s) is fine.
File Structure β
client_backend/
βββ src/
β βββ models/
β β βββ Benchmark.ts # NEW
β β βββ BenchmarkRun.ts # NEW
β βββ routes/
β β βββ benchmark.routes.ts # NEW (mounted under /api/superadmin/)
β βββ controllers/
β β βββ benchmark.controller.ts # NEW
β βββ services/
β β βββ benchmark.service.ts # NEW core orchestration
β βββ config/
β βββ modelPricing.ts # NEW cost estimation
Analysis/
βββ src/
β βββ pipeline/
β βββ benchmark_judge.py # NEW LLM-as-judge evaluator
oktivo_client/
βββ src/
β βββ pages/
β βββ superadmin/
β βββ BenchmarkCreator.jsx # NEW
β βββ BenchmarkResults.jsx # NEW
β βββ BenchmarkList.jsx # NEWAcceptance Criteria β
- [ ] SuperAdmin can create a benchmark with model matrix from the UI
- [ ] Benchmark expands matrix into combinations and shows total call count
- [ ] Benchmark executes calls sequentially, one combination at a time
- [ ] Each call reuses existing LiveKit dispatch β Agent β SIP β webhook pipeline
- [ ] Contacts created with
isTestCall: true - [ ] Metrics collected after each call: duration, latency (if available), cost estimate
- [ ] LLM judge scores each conversation on configurable dimensions
- [ ] Results page shows comparison table sortable by any metric
- [ ] Recordings are accessible from results page
- [ ] Benchmark doesn't consume customer credits (SuperAdmin-only, no billing)
- [ ] Benchmark calls don't count toward org concurrent call limits
- [ ] Temporary agent configs are cleaned up after benchmark completes
Future Enhancements (Not in This Issue) β
- Synthetic Callee: Deploy an AI that answers test calls and follows a script (simulates a real customer). This removes the need for a human to answer test calls.
- Scheduled Benchmarks: Run the same benchmark weekly to track model improvements over time.
- Auto A/B on Live Traffic: Route X% of real campaign calls through a different model combo and compare.
- Statistical Significance: Run enough calls per combo to compute confidence intervals on metrics.
- Cost Optimizer: Given a quality threshold, automatically recommend the cheapest model combo that meets it.