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