AI
OpenAI Realtime Voice Agent lewat SIP dengan gpt-realtime-2.1
Oktober 202613 menit baca

Panduan Realtime OpenAI menyebut gpt-realtime-2.1 sebagai model terbaru, dengan gpt-realtime-2.1-mini sebagai opsi yang lebih murah. Model lama gpt-realtime, gpt-4o-realtime, dan varian mini-nya dimatikan pada 20 Januari 2027, jadi pekerjaan baru sebaiknya langsung memakai keluarga 2.1. Panduan biaya menyarankan tuning di model penuh dulu, baru menguji versi mini.
Beli nomor dari penyedia SIP trunking lalu arahkan trunk ke sip:$PROJECT_ID@sip.api.openai.com;transport=tls. OpenAI kemudian mengirim webhook realtime.call.incoming ke project Anda, dan server Anda menerima panggilan berdasarkan call_id beserta konfigurasi session. Dengan Agents SDK, konfigurasi itu dibuat dengan OpenAIRealtimeSIP.buildInitialConfig, lalu RealtimeSession ditempelkan memakai transport OpenAIRealtimeSIP.
Antarmuka GA membuang header OpenAI-Beta: realtime=v1, mewajibkan session.type, memindahkan pengaturan audio output ke session.audio.output, dan mengganti nama event menjadi bentuk seperti response.output_audio.delta. Credential browser dibuat lewat POST /v1/realtime/client_secrets dan session WebRTC memakai /v1/realtime/calls. Antarmuka beta sudah dimatikan pada 12 Mei 2026, jadi kode beta tetap tersambung tetapi handler-nya tidak lagi cocok.
Tidak. Handoff di realtime memperbarui session yang sedang hidup dengan instructions dan tools agent baru, tetapi session memakai satu model sepanjang umurnya dan voice tidak bisa diganti setelah audio dihasilkan. Jika sebuah langkah butuh model lain, delegasikan lewat tool yang memanggil text agent biasa di server Anda lalu mengembalikan hasilnya ke session suara.
Audio ditagih per token: audio penelepon dihitung 1 token per 100 ms dan audio agent 1 token per 50 ms. Di gpt-realtime-2.1, audio input $32 dan output $64 per satu juta token, jadi satu menit agent berbicara sekitar $0,077 sebelum pembacaan ulang context. Karena seluruh percakapan dikirim ulang di setiap response, tarif input yang di-cache sebesar $0,40 per juta token yang menentukan tagihan sebenarnya, jadi jaga history tetap stabil dan periksa usage di response.done.

Ringkasan Utama
OpenAI realtime voice agent di 2026 berjalan di gpt-realtime-2.1 lewat Realtime API versi GA. Agents SDK membungkusnya dalam RealtimeAgent dan RealtimeSession, dan OpenAIRealtimeSIP menyambungkan session itu ke panggilan telepon yang diterima dari webhook realtime.call.incoming. Model realtime lama dimatikan pada 20 Januari 2027.
Permintaannya biasa saja: sebuah klinik ingin teleponnya tetap dijawab setelah resepsionis pulang, dalam Bahasa Indonesia, bisa mencarikan jadwal kosong dokter tertentu lalu menahannya. Prototipe pertama yang saya periksa adalah skrip Realtime beta berumur setahun. Koneksinya jalan, penelepon bilang halo, lalu tidak ada jawaban. Tidak ada error, tidak ada crash. Nama event yang ia dengarkan sudah tidak ada, jadi setiap potongan audio lewat begitu saja tanpa ditangani handler.
Tulisan ini membangun OpenAI realtime voice agent sesuai dokumentasi terbaru: RealtimeAgent dan RealtimeSession dari Agents SDK TypeScript di gpt-realtime-2.1, lengkap dengan tools, handoff, dan guardrail, plus nomor telepon sungguhan yang tersambung lewat SIP. Saya bahas apa yang rusak saat pindah dari beta ke GA, model lama mana yang berhenti bekerja pada 20 Januari 2027, dan bagaimana latency serta biaya berperilaku untuk jalur booking klinik di Indonesia. Semua nama API dan harga diambil dari dokumentasi OpenAI yang tercantum di akhir.
Realtime voice agent adalah satu session model speech-to-speech yang mendengar, memutuskan, dan berbicara dalam satu loop, tanpa pipeline speech-to-text dan text-to-speech terpisah yang harus Anda rangkai sendiri. Panduan Realtime dari OpenAI menyebut gpt-realtime-2.1 sebagai model terbaru dan menyediakan tiga jalur masuk: WebRTC dari browser, WebSocket dari server, dan SIP untuk panggilan telepon. Agents SDK memasang abstraksi agent yang sama di atas ketiganya, jadi memilih transport pada dasarnya adalah memilih di mana audio dan tools berada.
| Transport | Tempat session berjalan | Siapa yang mengurus audio | Cocok untuk |
|---|---|---|---|
| OpenAIRealtimeWebRTC | Browser, dengan ephemeral client secret yang dibuat di server Anda | SDK: rekam mikrofon dan pemutaran audio otomatis | Widget booking di website klinik |
| OpenAIRealtimeWebSocket | Server Anda | Anda: sendAudio untuk masuk, event audio untuk keluar, termasuk penanganan interupsi | Pipeline media custom dan bridge telephony yang Anda jalankan sendiri |
| OpenAIRealtimeSIP | Server Anda, menempel ke panggilan yang sudah ada lewat callId | Panggilan SIP itu sendiri: media mengalir antara operator dan OpenAI | Nomor telepon sungguhan lewat SIP trunk |
Dua batasan dari panduan build SDK menentukan semua keputusan desain setelah ini. Satu session memakai satu model sepanjang umurnya, dan voice hanya bisa diganti sebelum session menghasilkan audio apa pun. Selain itu, Realtime API saat ini membatasi satu session maksimal 60 menit. Panggilan klinik tidak akan sampai sepanjang itu, tetapi antrean call centre yang memarkir penelepon di agent bisa.
Prototipe yang diam itu bukan bug di skripnya. Halaman deprecations OpenAI mencatat bahwa antarmuka Realtime beta, yang dipilih dengan header OpenAI-Beta: realtime=v1, sudah dimatikan pada 12 Mei 2026. Kode yang ditulis untuk beta tidak gagal dengan keras; ia tersambung ke antarmuka yang menjawab dengan bentuk berbeda. Panduan Realtime mencantumkan perubahan yang penting:
// Wrong: beta-era client. The beta interface was shut down on 2026-05-12,
// so this header and these event names belong to an API that no longer exists.
const ws = new WebSocket(url, {
headers: { Authorization: "Bearer " + key, "OpenAI-Beta": "realtime=v1" },
});
ws.on("message", (raw) => {
const event = JSON.parse(raw.toString());
if (event.type === "response.audio.delta") play(event.delta); // never fires on GA
});
// Right: GA interface. No beta header, session.type is required,
// output audio config lives under session.audio.output.
ws.send(JSON.stringify({
type: "session.update",
session: {
type: "realtime",
model: "gpt-realtime-2.1",
audio: { output: { voice: "marin" } },
},
}));
ws.on("message", (raw) => {
const event = JSON.parse(raw.toString());
switch (event.type) {
case "response.output_audio.delta": play(event.delta); break;
case "response.output_audio_transcript.delta": log(event.delta); break;
case "response.output_text.delta": log(event.delta); break;
}
});Tenggat kedua lebih dekat dari kelihatannya. Pada 20 Juli 2026 OpenAI mengumumkan bahwa model realtime lama dimatikan pada 20 Januari 2027. Kalau masih ada file config yang menulis gpt-realtime atau gpt-4o-realtime, sisa waktunya kurang dari empat bulan, dan nama penggantinya bukan yang paling mudah ditebak.
| Model lama | Dimatikan | Pengganti |
|---|---|---|
| gpt-realtime, gpt-4o-realtime | 20 Januari 2027 | gpt-realtime-2.1 |
| gpt-realtime-mini, gpt-4o-mini-realtime | 20 Januari 2027 | gpt-realtime-2.1-mini |
| gpt-4o-realtime-preview dan snapshot bertanggalnya | Sudah dimatikan pada 7 Mei 2026 | Saat itu gpt-realtime-1.5, sekarang gpt-realtime-2.1 |
Bentuk RealtimeAgent sama dengan text agent: name, instructions, tools yang didefinisikan dengan helper tool() dan schema zod, serta daftar handoff. RealtimeSession lalu mengikat agent itu ke sebuah model dan transport. Resepsionis di bawah punya dua tool, satu untuk membaca slot kosong dan satu untuk menahan slot, ditambah agent spesialis billing yang bisa menerima alihan panggilan.
import { RealtimeAgent, RealtimeSession, tool } from "@openai/agents/realtime";
import { z } from "zod";
import { clinicApi } from "./clinic-api"; // your backend, never the model's
const findSlots = tool({
name: "find_slots",
description: "List open appointment slots for a doctor on a date (Asia/Jakarta).",
parameters: z.object({
doctorId: z.string(),
date: z.string().describe("YYYY-MM-DD"),
}),
timeoutMs: 4000, // a caller hears dead air while a tool runs; fail fast
async execute({ doctorId, date }) {
return clinicApi.openSlots(doctorId, date);
},
});
const bookSlot = tool({
name: "book_slot",
description: "Hold a slot for a verified patient. Creates a booking, so ask first.",
parameters: z.object({ slotId: z.string(), patientPhone: z.string() }),
needsApproval: true, // on a phone there is no button: the server decides
async execute({ slotId, patientPhone }) {
return clinicApi.hold(slotId, patientPhone);
},
});
const billingAgent = new RealtimeAgent({
name: "Billing",
handoffDescription: "Questions about invoices, BPJS coverage and payment",
instructions: "Answer billing questions in the caller's language. Never quote a price you did not get from a tool.",
});
export const receptionist = new RealtimeAgent({
name: "Receptionist",
instructions:
"You answer the phone for a clinic in Jakarta. Speak Bahasa Indonesia unless the caller uses English. " +
"Before calling a tool, say a short filler such as 'sebentar, saya cek jadwalnya'.",
tools: [findSlots, bookSlot],
handoffs: [billingAgent],
});
export const sessionOptions = {
model: "gpt-realtime-2.1",
config: {
outputModalities: ["audio"],
reasoning: { effort: "low" }, // higher effort costs latency on every turn
audio: {
input: {
transcription: {
model: "gpt-live-transcribe",
languages: ["id", "en"],
keywords: ["BPJS", "dr. Sari", "poli anak"],
},
turnDetection: { type: "semantic_vad", eagerness: "medium", interruptResponse: true },
},
output: { voice: "marin" },
},
},
} as const;Ada tiga pilihan yang disengaja di config itu. Blok transcription mencantumkan id dan en di languages serta mengisi keywords dengan nama dokter dan BPJS, karena penelepon sering mencampur bahasa di tengah kalimat dan nama dokter yang salah dengar menghasilkan booking yang meyakinkan dengan orang yang salah. Reasoning effort diset low, karena panduan build memperingatkan bahwa effort lebih tinggi menambah latency dan token di setiap giliran. Dan setiap tool punya timeoutMs, karena panduan build tegas menyatakan bahwa selama tool berjalan agent tidak bisa memproses permintaan baru dari penelepon, jadi database klinik yang lambat terdengar sebagai keheningan.
Package: @openai/agents untuk RealtimeAgent, RealtimeSession, tool, dan transport di bawah @openai/agents/realtime, zod untuk schema tool, dan openai untuk verifikasi webhook serta kontrol panggilan.
Handoff di realtime bukan handoff yang Anda kenal dari text agent. SDK memperbarui session yang sedang hidup secara langsung dengan instructions dan tools agent baru, sehingga agent billing mewarisi seluruh percakapan dan input filter tidak diterapkan. Modelnya tidak bisa berganti, dan karena resepsionis sudah berbicara, voice-nya juga tidak. Kalau sebuah langkah memang butuh model lain, misalnya reasoning model untuk memeriksa refund, jawaban panduan build adalah delegasi lewat tool: tool mengirim permintaan dan snapshot history ke Agent teks biasa di server Anda, lalu hasilnya diucapkan.
Guardrail berjalan pada apa yang diucapkan agent, bukan apa yang diucapkan penelepon. Di session audio, SDK memeriksa transcript output saat di-stream, setiap 100 karakter secara default dan sekali lagi pada transcript final. Mengucapkan kalimat butuh waktu lebih lama daripada menghasilkan transcript-nya, jadi guardrail yang terpicu biasanya memotong response sebelum penelepon mendengar frasa yang bermasalah. Untuk klinik, frasa yang tidak boleh pernah diucapkan adalah diagnosis.
import { RealtimeSession, type RealtimeOutputGuardrail } from "@openai/agents/realtime";
const noDiagnosis: RealtimeOutputGuardrail = {
name: "No medical diagnosis",
async execute({ agentOutput }) {
const diagnosing = /\b(diagnosis|diagnosa|you have|anda menderita)\b/i.test(agentOutput);
return { tripwireTriggered: diagnosing, outputInfo: { diagnosing } };
},
};
// callId is ours: it came from the webhook, not from the model. The From header
// is caller-supplied SIP metadata, so it is a lookup hint, never proof of identity.
export function createGuardedSession(callId: string) {
const session = new RealtimeSession(receptionist, {
...sessionOptions,
outputGuardrails: [noDiagnosis],
outputGuardrailSettings: { debounceTextLength: 100 }, // the default; -1 = only at the end
toolExecution: { preApprovalInputGuardrails: true },
});
session.on("guardrail_tripped", () => {
metrics.increment("voice.guardrail_tripped"); // the response is already cut off
});
// No UI on a phone line: approval is a policy check against state your server
// recorded (an OTP or date-of-birth match for this callId), never the model's arguments.
session.on("tool_approval_requested", async (_ctx, _agent, request) => {
const verified = await clinicApi.isCallVerified(callId);
if (verified) await session.approve(request.approvalItem);
else await session.reject(request.approvalItem, {
message: "Caller not verified. Offer to send a WhatsApp confirmation link instead.",
});
});
return session;
}Persetujuan tool perlu dipikir ulang di telepon. Di browser, needsApproval menjeda panggilan dan menampilkan tombol. Di panggilan SIP tidak ada yang bisa menekan tombol, jadi handler tool_approval_requested berubah menjadi pemeriksaan policy di server: setujui book_slot hanya jika backend Anda sudah mencatat verifikasi untuk panggilan ini. Opsi preApprovalInputGuardrails menjalankan input guardrail tool sebelum event approval dan juga sesudahnya, jadi argumen yang jelas salah ditolak tanpa perlu sampai ke pemeriksaan policy.
Function tool berjalan di tempat RealtimeSession berjalan. Pada setup WebRTC di browser, artinya di browser, tempat siapa pun bisa membuka developer tools dan memanggil clinicApi.hold secara langsung. Simpan tool yang punya hak istimewa di balik backend Anda yang terautentikasi, atau jalankan session di server dengan transport SIP atau WebSocket. Lakukan otorisasi berdasarkan konteks session yang tepercaya, jangan pernah berdasarkan argumen dari model.
Jalur SIP menjaga audio panggilan tetap mengalir antara operator Anda dan OpenAI; server Anda hanya mengurus webhook, konfigurasi, dan tools. Urutan setup sesuai panduan SIP:
import express from "express";
import OpenAI from "openai";
import { OpenAIRealtimeSIP, RealtimeSession } from "@openai/agents/realtime";
import { receptionist, sessionOptions } from "./receptionist";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
webhookSecret: process.env.OPENAI_WEBHOOK_SECRET,
});
const seen = new Set<string>(); // use Redis in production; webhooks are retried
const app = express();
app.post("/webhooks/openai", express.text({ type: "*/*" }), async (req, res) => {
// Signature check needs the raw body, so no express.json() on this route.
const event = await openai.webhooks.unwrap(req.body, req.headers);
if (event.type !== "realtime.call.incoming") return res.sendStatus(200);
const webhookId = String(req.headers["webhook-id"]);
if (seen.has(webhookId)) return res.sendStatus(200);
seen.add(webhookId);
const callId = event.data.call_id;
const config = await OpenAIRealtimeSIP.buildInitialConfig(receptionist, sessionOptions);
await openai.realtime.calls.accept(callId, config);
res.sendStatus(200);
const session = new RealtimeSession(receptionist, {
transport: new OpenAIRealtimeSIP(),
...sessionOptions,
});
await session.connect({ apiKey: process.env.OPENAI_API_KEY!, callId });
});
// Hand the caller to a human front desk: a SIP REFER, relayed to your trunk.
export async function transferToFrontDesk(callId: string) {
await fetch("https://api.openai.com/v1/realtime/calls/" + callId + "/refer", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.OPENAI_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ target_uri: "tel:+622150000000" }),
});
}Ada dua jebakan di alur itu. buildInitialConfig langsung throw di lokal jika config turn detection berisi threshold, prefixPaddingMs, atau silenceDurationMs, karena calls API menolak field tersebut untuk session SIP; semantic_vad dengan interruptResponse adalah pilihan yang aman. Selain itu webhook dikirim ulang saat gagal, jadi lakukan deduplikasi berdasarkan header webhook-id sebelum menerima panggilan, atau satu dering berubah menjadi dua percobaan attach yang saling berebut.
Sisi jaringan mudah terlupa sampai ada panggilan yang berdering tanpa suara. Signalling butuh TLS keluar di port 5061 ke alamat hasil resolve sip.api.openai.com, dan media berupa SRTP lewat UDP ke empat rentang CIDR yang tercantum di panduan SIP. Saat agent harus menyerah, POST ke endpoint refer dengan target tel: atau sip: mengirim SIP REFER ke trunk Anda dan resepsionis klinik mengambil alih panggilan; endpoint hangup mengakhirinya dengan rapi.
Saya tidak bisa memberi angka milidetik yang berlaku untuk operator Anda, dan tidak ada yang bisa melakukannya dengan jujur. Yang ditetapkan dokumentasi adalah di mana delay itu bertambah, dan setiap poin bisa Anda kendalikan:
Perbaikan termurah ada di prompt: minta agent mengucapkan kalimat pengisi singkat sebelum memanggil tool, seperti di instructions resepsionis. SDK juga menyediakan backgroundResult untuk tool yang output-nya perlu dikirim tanpa langsung memicu response lisan berikutnya, cocok untuk konfirmasi yang tidak perlu dibacakan ulang ke penelepon.
Rekam panggilan uji lewat trunk sungguhan dari nomor seluler Indonesia sungguhan sebelum menyetel apa pun. Menelepon agent dari browser di kota yang sama dengan server Anda mengukur jalur yang salah, dan setiap keputusan tuning yang didasarkan padanya akan meleset sebesar hop internasional itu.
Billing Realtime dihitung per token, dan audio punya rasio token yang tetap: panduan biaya menghitung audio penelepon 1 token per 100 ms dan audio asisten 1 token per 50 ms, jadi satu menit agent berbicara setara 1.200 token dan satu menit penelepon berbicara setara 600 token. Halaman model mencantumkan harga per satu juta token audio:
| Item | gpt-realtime-2.1 | gpt-realtime-2.1-mini |
|---|---|---|
| Audio input, per 1 juta token | $32 | $10 |
| Audio input yang di-cache, per 1 juta token | $0,40 | $0,30 |
| Audio output, per 1 juta token | $64 | $20 |
| Satu menit agent berbicara | sekitar $0,077 | sekitar $0,024 |
| Satu menit penelepon berbicara, saat pertama kali dikirim | sekitar $0,019 | sekitar $0,006 |
Baris per menit itu adalah batas bawah, bukan tagihan, karena seluruh percakapan dikirim ulang ke model sebagai input di setiap response. Untuk panggilan booking empat menit dengan sekitar dua menit suara penelepon, sembilan puluh detik suara agent, dan dua belas giliran, hitungan saya di gpt-realtime-2.1 menghasilkan sekitar $0,15 audio baru ditambah kira-kira 18.000 token audio yang dibaca ulang. Jika pembacaan ulang itu kena cache, tambahannya kurang dari satu sen; jika cache meleset di setiap giliran, tambahannya lebih dari setengah dolar. Dua tarif input resmi itu berselisih 80 kali lipat, dan selisih itulah yang menentukan hitungan ekonominya. Batasi context supaya panggilan panjang tidak membengkak tanpa batas:
{
"type": "session.update",
"session": {
"truncation": {
"type": "retention_ratio",
"retention_ratio": 0.8,
"token_limits": { "post_instructions": 8000 }
}
}
}Ada dua hal yang merusak cache itu. Panduan biaya menyebut bahwa mengedit atau menghapus item percakapan membatalkan cache mulai dari titik perubahan, dan bahwa instructions serta tools berada di awal percakapan, jadi mengubahnya di tengah session mengorbankan cache hit untuk semua giliran sesudahnya. Handoff melakukan persis hal itu: SDK menukar instructions dan tools agent aktif di tempat. Saya membatasi handoff menjadi satu per panggilan dan menaruh teks yang stabil di depan. Saran panduan biaya sendiri adalah mulai dengan model penuh, rapikan prompt, lalu uji model mini, yang lebih lemah dalam mengikuti instruksi dan function calling. Estimasi saya belum termasuk token teks untuk instructions dan tools, transkripsi input, serta biaya per menit dari penyedia trunk, jadi anggap sebagai batas bawah dan cocokkan dengan blok usage di response.done.
Aturan yang saya ambil dari prototipe yang diam itu: voice agent gagal tanpa suara, jadi periksa terhadap nama-nama yang berlaku sekarang, bukan sekadar apakah ia tersambung. Kunci gpt-realtime-2.1 atau gpt-realtime-2.1-mini sebelum 20 Januari 2027, terima panggilan SIP dengan buildInitialConfig, otorisasi tools di server, ucapkan sesuatu sebelum setiap tool yang lambat, dan baca usage response.done dari panggilan sungguhan sebelum menjanjikan harga per panggilan kepada siapa pun.
Sumber dan bacaan lanjutan