diff --git a/client/components/App.jsx b/client/components/App.jsx
index 77f9801a9..894274699 100644
--- a/client/components/App.jsx
+++ b/client/components/App.jsx
@@ -1,8 +1,11 @@
+// client/components/App.jsx
import { useEffect, useRef, useState } from "react";
import logo from "/assets/openai-logomark.svg";
import EventLog from "./EventLog";
import SessionControls from "./SessionControls";
import ToolPanel from "./ToolPanel";
+import { computeCostFromUsage } from "./billingFromUsage";
+import BillingSummary from "./BillingSummary";
export default function App() {
const [isSessionActive, setIsSessionActive] = useState(false);
@@ -11,31 +14,72 @@ export default function App() {
const peerConnection = useRef(null);
const audioElement = useRef(null);
+ // Billing
+ const [totalBilling, setTotalBilling] = useState(0);
+ const [totalBreakdown, setTotalBreakdown] = useState({
+ costInText: 0,
+ costInAudio: 0,
+ costCached: 0,
+ costOutText: 0,
+ costOutAudio: 0,
+ });
+ const billedEventIdsRef = useRef(new Set());
+
+ // --- Helper: recalc billing ---
+ function recalcBilling(allEvents) {
+ const sorted = allEvents
+ .filter(ev => ev.billing)
+ .sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
+
+ let total = 0;
+ const breakdown = {
+ costInText: 0,
+ costInAudio: 0,
+ costCached: 0,
+ costOutText: 0,
+ costOutAudio: 0,
+ };
+
+ sorted.forEach(ev => {
+ const b = ev.billing.breakdown;
+ total += ev.billing.total;
+ breakdown.costInText += b.costInText;
+ breakdown.costInAudio += b.costInAudio;
+ breakdown.costCached += b.costCached;
+ breakdown.costOutText += b.costOutText;
+ breakdown.costOutAudio += b.costOutAudio;
+ });
+
+ return { totalBilling: total, totalBreakdown: breakdown };
+ }
+
async function startSession() {
- // Get a session token for OpenAI Realtime API
const tokenResponse = await fetch("/token");
const data = await tokenResponse.json();
const EPHEMERAL_KEY = data.value;
- // Create a peer connection
const pc = new RTCPeerConnection();
- // Set up to play remote audio from the model
audioElement.current = document.createElement("audio");
audioElement.current.autoplay = true;
pc.ontrack = (e) => (audioElement.current.srcObject = e.streams[0]);
- // Add local audio track for microphone input in the browser
- const ms = await navigator.mediaDevices.getUserMedia({
- audio: true,
- });
+ const ms = await navigator.mediaDevices.getUserMedia({ audio: true });
pc.addTrack(ms.getTracks()[0]);
- // Set up data channel for sending and receiving events
const dc = pc.createDataChannel("oai-events");
setDataChannel(dc);
- // Start the session using the Session Description Protocol (SDP)
+ pc.ondatachannel = (evt) => {
+ const incoming = evt.channel;
+ incoming.onmessage = (e) => {
+ try {
+ const msg = JSON.parse(e.data);
+ console.log("Incoming dataChannel (server → client):", msg);
+ } catch {}
+ };
+ };
+
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
@@ -51,95 +95,99 @@ export default function App() {
});
const sdp = await sdpResponse.text();
- const answer = { type: "answer", sdp };
- await pc.setRemoteDescription(answer);
+ await pc.setRemoteDescription({ type: "answer", sdp });
peerConnection.current = pc;
}
- // Stop current session, clean up peer connection and data channel
function stopSession() {
- if (dataChannel) {
- dataChannel.close();
- }
-
- peerConnection.current.getSenders().forEach((sender) => {
- if (sender.track) {
- sender.track.stop();
- }
- });
-
- if (peerConnection.current) {
- peerConnection.current.close();
- }
+ dataChannel?.close();
+ peerConnection.current?.getSenders().forEach(sender => sender.track?.stop());
+ peerConnection.current?.close();
setIsSessionActive(false);
setDataChannel(null);
peerConnection.current = null;
+
+ setTotalBilling(0);
+ setTotalBreakdown({
+ costInText: 0,
+ costInAudio: 0,
+ costCached: 0,
+ costOutText: 0,
+ costOutAudio: 0,
+ });
+ billedEventIdsRef.current.clear();
}
- // Send a message to the model
function sendClientEvent(message) {
if (dataChannel) {
- const timestamp = new Date().toLocaleTimeString();
+ const timestamp = new Date().toISOString();
message.event_id = message.event_id || crypto.randomUUID();
-
- // send event before setting timestamp since the backend peer doesn't expect this field
+ message.timestamp = message.timestamp || timestamp;
dataChannel.send(JSON.stringify(message));
-
- // if guard just in case the timestamp exists by miracle
- if (!message.timestamp) {
- message.timestamp = timestamp;
- }
setEvents((prev) => [message, ...prev]);
- } else {
- console.error(
- "Failed to send message - no data channel available",
- message,
- );
}
}
- // Send a text message to the model
function sendTextMessage(message) {
const event = {
type: "conversation.item.create",
- item: {
- type: "message",
- role: "user",
- content: [
- {
- type: "input_text",
- text: message,
- },
- ],
- },
+ item: { type: "message", role: "user", content: [{ type: "input_text", text: message }] },
};
-
sendClientEvent(event);
sendClientEvent({ type: "response.create" });
}
- // Attach event listeners to the data channel when a new one is created
useEffect(() => {
- if (dataChannel) {
- // Append new server events to the list
- dataChannel.addEventListener("message", (e) => {
- const event = JSON.parse(e.data);
- if (!event.timestamp) {
- event.timestamp = new Date().toLocaleTimeString();
+ if (!dataChannel) return;
+
+ const onMessage = (e) => {
+ let event;
+ try { event = JSON.parse(e.data); }
+ catch { return; }
+
+ // If the billing is already computed
+ if ((event.type === "response.done" || event.type === "response.completed") && event.response?.usage) {
+ const eventId = event.event_id || event.response?.id;
+
+ if (!billedEventIdsRef.current.has(eventId)) {
+ const cost = computeCostFromUsage(event.response.usage);
+ event.billing = cost;
+ billedEventIdsRef.current.add(eventId);
}
- setEvents((prev) => [event, ...prev]);
- });
+ // Recalculation by chronology
+ const { totalBilling, totalBreakdown } = recalcBilling([event, ...events]);
+ setTotalBilling(totalBilling);
+ setTotalBreakdown(totalBreakdown);
+ }
+
+ if (!event.timestamp) event.timestamp = new Date().toISOString();
+ setEvents((prev) => [event, ...prev]);
+ };
- // Set session active when the data channel is opened
- dataChannel.addEventListener("open", () => {
- setIsSessionActive(true);
- setEvents([]);
+ const onOpen = () => {
+ setIsSessionActive(true);
+ setEvents([]);
+ setTotalBilling(0);
+ setTotalBreakdown({
+ costInText: 0,
+ costInAudio: 0,
+ costCached: 0,
+ costOutText: 0,
+ costOutAudio: 0,
});
- }
- }, [dataChannel]);
+ billedEventIdsRef.current.clear();
+ };
+
+ dataChannel.addEventListener("message", onMessage);
+ dataChannel.addEventListener("open", onOpen);
+ return () => {
+ dataChannel.removeEventListener("message", onMessage);
+ dataChannel.removeEventListener("open", onOpen);
+ };
+ }, [dataChannel, events]);
return (
<>
@@ -147,6 +195,9 @@ export default function App() {
realtime console
+
+ Session cost: ${totalBilling.toFixed(6)}
+
@@ -177,3 +228,5 @@ export default function App() {
>
);
}
+
+
diff --git a/client/components/BillingSummary.jsx b/client/components/BillingSummary.jsx
new file mode 100644
index 000000000..7a5d17970
--- /dev/null
+++ b/client/components/BillingSummary.jsx
@@ -0,0 +1,43 @@
+// client/components/BillingSummary.jsx
+import React from "react";
+
+export default function BillingSummary({ totalBilling, totalBreakdown, billedEvents }) {
+ return (
+
+
Billing summary
+
+
+
Session total
+
${(totalBilling || 0).toFixed(6)}
+
+
+
Accumulated breakdown
+
+
in_text: ${totalBreakdown.costInText?.toFixed(6) ?? "0.000000"}
+
in_audio: ${totalBreakdown.costInAudio?.toFixed(6) ?? "0.000000"}
+
cached: ${totalBreakdown.costCached?.toFixed(6) ?? "0.000000"}
+
out_text: ${totalBreakdown.costOutText?.toFixed(6) ?? "0.000000"}
+
out_audio: ${totalBreakdown.costOutAudio?.toFixed(6) ?? "0.000000"}
+
+
+
Recent billed events
+
+ {billedEvents && billedEvents.length > 0 ? (
+ billedEvents.map((ev) => (
+
+
{ev.timestamp || "—"}
+
+ ${ev.billing.total.toFixed(6)} — {ev.type}
+
+
+ tokens: in_text={ev.billing.tokens.inputText}, in_audio={ev.billing.tokens.inputAudio}, out_text={ev.billing.tokens.outputText}, out_audio={ev.billing.tokens.outputAudio}
+
+
+ ))
+ ) : (
+
No billed events yet
+ )}
+
+
+ );
+}
diff --git a/client/components/EventLog.jsx b/client/components/EventLog.jsx
index 2d72a701a..982203764 100644
--- a/client/components/EventLog.jsx
+++ b/client/components/EventLog.jsx
@@ -1,13 +1,14 @@
+// client/components/EventLog.jsx
import { ArrowUp, ArrowDown } from "react-feather";
-import { useState } from "react";
+import { useState, useMemo } from "react";
-function Event({ event, timestamp }) {
+// Single Event Component
+function Event({ event, timestamp, accumulated }) {
const [isExpanded, setIsExpanded] = useState(false);
-
const isClient = event.event_id && !event.event_id.startsWith("event_");
return (
-
+
setIsExpanded(!isExpanded)}
@@ -18,43 +19,76 @@ function Event({ event, timestamp }) {
)}
- {isClient ? "client:" : "server:"}
- {event.type} | {timestamp}
+ {isClient ? "client:" : "server:"} {event.type} | {timestamp}
+ {event.billing && (
+
+
+ Billing: ${event.billing.total.toFixed(6)}
+
+
+ Accumulated: ${accumulated.toFixed(6)}
+
+
+ )}
-
-
{JSON.stringify(event, null, 2)}
-
+
+ {isExpanded && (
+
+
{JSON.stringify(event, null, 2)}
+
+ {event.billing && (
+
+
+ Request cost: ${event.billing.total.toFixed(6)}
+ Accumulated: ${accumulated.toFixed(6)}
+
+
+
Breakdown
+
in_text: ${event.billing.breakdown.costInText.toFixed(6)}
+
in_audio: ${event.billing.breakdown.costInAudio.toFixed(6)}
+
cached: ${event.billing.breakdown.costCached.toFixed(6)}
+
out_text: ${event.billing.breakdown.costOutText.toFixed(6)}
+
out_audio: ${event.billing.breakdown.costOutAudio.toFixed(6)}
+
+
+ {JSON.stringify(event.billing.tokens, null, 2)}
+
+
+ )}
+
+ )}
);
}
+// The main component of event logs
export default function EventLog({ events }) {
- const eventsToDisplay = [];
- let deltaEvents = {};
+ // Sort events **by chronology**
+ const sortedEvents = useMemo(() => {
+ return [...events]
+ .filter(ev => ev.billing || ev.type === "session.created")
+ .sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
+ }, [events]);
- events.forEach((event) => {
- if (event.type.endsWith("delta")) {
- if (deltaEvents[event.type]) {
- // for now just log a single event per render pass
- return;
- } else {
- deltaEvents[event.type] = event;
- }
- }
+ // Accumulated amount
+ let runningTotal = 0;
- eventsToDisplay.push(
-
,
+ const eventsToDisplay = sortedEvents.map(event => {
+ if (event.billing) runningTotal += event.billing.total ?? 0;
+ return (
+
);
});
return (
- {events.length === 0 ? (
+ {eventsToDisplay.length === 0 ? (
Awaiting events...
) : (
eventsToDisplay
@@ -62,3 +96,6 @@ export default function EventLog({ events }) {
);
}
+
+
+
diff --git a/client/components/billingFromUsage.js b/client/components/billingFromUsage.js
new file mode 100644
index 000000000..4f28c957d
--- /dev/null
+++ b/client/components/billingFromUsage.js
@@ -0,0 +1,62 @@
+// client/components/billingFromUsage.js
+
+// GPT-4o Realtime Prices (September 2025)
+// $4.00 / 1M input tokens (text)
+// $16.00 / 1M output tokens (text)
+// $0.40 / 1M cached input tokens
+// $32.00 / 1M input audio tokens
+// $64.00 / 1M output audio tokens
+
+const RATES_PER_1M = {
+ text_input: 4.0,
+ text_output: 16.0,
+ cached_input: 0.4,
+ audio_input: 32.0,
+ audio_output: 64.0,
+};
+
+const RATE = {
+ text_input: RATES_PER_1M.text_input / 1_000_000,
+ text_output: RATES_PER_1M.text_output / 1_000_000,
+ cached_input: RATES_PER_1M.cached_input / 1_000_000,
+ audio_input: RATES_PER_1M.audio_input / 1_000_000,
+ audio_output: RATES_PER_1M.audio_output / 1_000_000,
+};
+
+export function computeCostFromUsage(usage) {
+ if (!usage) {
+ return { total: 0, breakdown: {}, tokens: {} };
+ }
+
+ const inputText = usage.input_token_details?.text_tokens ?? 0;
+ const inputAudio = usage.input_token_details?.audio_tokens ?? 0;
+ const cached = usage.input_token_details?.cached_tokens ?? 0;
+ const outputText = usage.output_token_details?.text_tokens ?? 0;
+ const outputAudio = usage.output_token_details?.audio_tokens ?? 0;
+
+ const costInText = Math.max(0, inputText - cached) * RATE.text_input;
+ const costInAudio = inputAudio * RATE.audio_input;
+ const costCached = cached * RATE.cached_input;
+ const costOutText = outputText * RATE.text_output;
+ const costOutAudio = outputAudio * RATE.audio_output;
+
+ const total = costInText + costInAudio + costCached + costOutText + costOutAudio;
+
+ return {
+ total,
+ breakdown: {
+ costInText,
+ costInAudio,
+ costCached,
+ costOutText,
+ costOutAudio,
+ },
+ tokens: {
+ inputText,
+ inputAudio,
+ cached,
+ outputText,
+ outputAudio,
+ },
+ };
+}
diff --git "a/client/components/billingFromUsage.js\357\200\272Zone.Identifier" "b/client/components/billingFromUsage.js\357\200\272Zone.Identifier"
new file mode 100644
index 000000000..e69de29bb