diff --git a/.github/workflows/cost-calculator-pages.yml b/.github/workflows/cost-calculator-pages.yml
new file mode 100644
index 0000000..cccac68
--- /dev/null
+++ b/.github/workflows/cost-calculator-pages.yml
@@ -0,0 +1,68 @@
+name: Cost calculator Pages
+
+on:
+ pull_request:
+ paths:
+ - "tools/cost_calculator/**"
+ - ".github/workflows/cost-calculator-pages.yml"
+ push:
+ branches: [main]
+ paths:
+ - "tools/cost_calculator/**"
+ - ".github/workflows/cost-calculator-pages.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "24"
+ - name: Check calculator
+ working-directory: tools/cost_calculator
+ shell: pwsh
+ run: |
+ node --test calculator.test.mjs
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ node --check app.mjs
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ node --check server.mjs
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ deploy:
+ if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
+ needs: validate
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pages: write
+ id-token: write
+ concurrency:
+ group: github-pages
+ cancel-in-progress: false
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/configure-pages@v5
+ - name: Stage browser assets only
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = "Stop"
+ $site = Join-Path $env:RUNNER_TEMP "cost-calculator-site"
+ New-Item -ItemType Directory -Path $site | Out-Null
+ foreach ($file in @("index.html", "styles.css", "app.mjs", "calculator.mjs", "prices.json")) {
+ Copy-Item -LiteralPath (Join-Path (Join-Path "tools" "cost_calculator") $file) -Destination $site
+ }
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: ${{ runner.temp }}/cost-calculator-site
+ - name: Deploy GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/samples/README.md b/samples/README.md
index 2806341..5f94655 100644
--- a/samples/README.md
+++ b/samples/README.md
@@ -18,6 +18,7 @@ independent shared MCP reference implementation, start with the
| Sample | What it demonstrates |
| --- | --- |
+| [Voice Agent Cost Lab](../tools/cost_calculator/README.md) | Standalone browser list-price calculator in `tools`: South India model prices, labeled Central India HD/Voice Live references, managed/BYOM model selection, prompt sensitivity, and CSV export. No Azure credentials required. |
| [Add voice to an existing customer agent](existing_agent_voice_design/README.md) | Design guide: use a Hosted Agent as the Foundry Voice Agent conversation engine to connect managed voice to an existing customer agent or API endpoint. Includes a high-level architecture diagram; not a runnable sample. |
| [Voice Live to Voice Agent migration](voice_live_to_voice_agent/README.md) | Compare the same Azure Speech -> GPT-4.1 mini -> Azure TTS cascade and shared local functions before and after migration. Includes dynamic session context and mid-session instruction-update examples. |
| [Vehicle voice agent with local tools](vehicle_local_tools/README.md) | Web cockpit with cloud voice interaction and locally executed simulated climate, media, and navigation functions. Includes an offline dashboard, local policy enforcement, and a tool execution log. |
diff --git a/tools/cost_calculator/README.md b/tools/cost_calculator/README.md
new file mode 100644
index 0000000..9da9972
--- /dev/null
+++ b/tools/cost_calculator/README.md
@@ -0,0 +1,166 @@
+# Voice Agent Cost Lab
+
+Standalone browser calculator with **South India / USD / pay-as-you-go**
+model-price references.
+Compare managed Voice Agent / Voice Live tiers and **bring your own model
+(BYOM)** using list-price estimates.
+BYOM models and GPT Live use **South India Global** prices from Azure OpenAI.
+**Azure HD voice uses Central India rates** as requested. No South India
+Voice Live tier or BYO speech meters were found, so managed tiers and BYO STT
+also use the approved, explicitly labeled **Central India references**.
+No Azure credentials, packages, build step, or live model calls are needed.
+
+## Run
+
+With Node.js 20 or later, from the repository root:
+
+```powershell
+node tools\cost_calculator\server.mjs
+```
+
+Open **http://127.0.0.1:8095**. To change the port, set `$env:PORT = "8096"`
+before running the command. The server listens only on the loopback interface.
+Stop it with Ctrl+C. Opening `index.html` as `file://` will not work because
+the page loads modules and the local price snapshot.
+
+Choose hosting, pipeline, and model; change call assumptions or deployment
+rates. The tool shows per-call, per-minute, call-volume list prices, component costs, and
+comparisons at **0, 1K, 5K, 10K, and 20K** system-prompt tokens. Export CSV
+includes list-price amounts, assumptions, overrides, snapshot date, and
+component reference regions, processing scopes, and meter IDs.
+The CSV also distinguishes managed-tier pricing from AOAI Global economic
+references when a model has no published managed tier.
+Changes are in memory only and reset on reload.
+
+## Deploy
+
+This is a static page: calculations run in the browser and `prices.json` is
+bundled with the page. The Node server is for local preview only.
+
+The [Pages workflow](../../.github/workflows/cost-calculator-pages.yml)
+checks calculator changes in pull requests and publishes the site when those
+changes merge into `main`. It can also be run manually from `main` in the
+Actions tab. Pull requests and other branches cannot deploy.
+
+The repository must have **Settings > Pages > Source > GitHub Actions**
+enabled. The deployment publishes exactly `index.html`, `styles.css`,
+`app.mjs`, `calculator.mjs`, and `prices.json` at the site's root, excluding
+the local server, tests, documentation, and all other repository files.
+No dependency installation or build command is needed. All asset URLs are
+relative, so the page works under a repository subpath.
+
+For this repository, the published address is
+**https://microsoft.github.io/voice-agent/** after the first successful
+deployment. The site is public; do not add confidential data or credentials.
+
+**Azure Static Web Apps** is an alternative, especially when access control
+is required. Set the app location to this tool's directory, leave the API
+location empty, and deploy the existing assets without a build. Configure
+authentication and route access rules if the page must not be public.
+
+Deploy only the five browser assets above; no server, credentials, or live
+Azure connection is needed. Pricing updates require updating the snapshot
+and redeploying.
+
+## Price basis
+
+`prices.json` is a **2026-10-22 snapshot**, not a live price feed, from the
+[Azure Retail Prices API](https://prices.azure.com/api/retail/prices).
+It records original billing units, exact USD prices, meter IDs, filters, and
+effective dates where returned. Rates per 1K are multiplied by 1,000 for the
+calculator's USD-per-million fields.
+The GPT Live meter is hourly and is divided by 60 for USD-per-minute pricing.
+
+- Managed models use Central India's Pro, Standard, or Lite **Voice Live
+ meter references**, not
+ direct OpenAI model API prices. GPT Realtime 2.1 uses the Pro text-output-2
+ meter; `gpt-realtime` uses the original output meter.
+- When no managed tier is published, use the exact model's **AOAI Global
+ inference prices + Central India BYO speech** as an economic reference.
+ This fills GPT-5.4 mini/nano, GPT OSS 120B, and GPT-6 Sol reference scenarios.
+ The UI and CSV explicitly label these **not confirmed managed Voice Live
+ tariffs**. A model price does not prove managed Voice Agent availability.
+- Azure HD uses the **Central India** Voice Live standard-speech output meter. This is not
+ standalone Speech's character-billed HD price.
+- BYOM uses **Central India Voice Live BYO standard-speech input/output**
+ references plus the selected model's **South India Global** deployment
+ prices. GPT-4.1, GPT-5.1, GPT-5.2, GPT-5.4, GPT Realtime 1.0/1.5/2.1,
+ GPT Realtime 2.1 mini, GPT OSS 120B, and GPT-6 Sol are prefilled from the
+ published South India meters. Global describes processing scope, not a
+ promise of India-only inference.
+- **GPT-6 reasoning is explicitly GPT-6 Sol**, using South India Global
+ short-context Standard prices: **$2 input / $0.20 cached input / $10 output
+ per 1M tokens**. The generic, unspecified GPT-6 placeholder is replaced
+ with the exact priced variant. Reasoning tokens use its output rate.
+- No cached OSS price was found, so replayed OSS text uses its full input
+ rate. You can explicitly override that conservative assumption.
+- **GPT Live (`gpt-live-1`) voice sessions:** Azure OpenAI Media's `Live 1 Gl Unit`
+ meter lists **$3/hour = $0.05/minute** for South India Global, effective
+ 2026-09-01. Meter ID: `2e5b0a5c-3723-50a8-ba75-a5473f702fc1`. This is the
+ South India Global reference, **not a Central India rate or an
+ availability guarantee**. A 90-second session costs **$0.075 for voice**,
+ before separately billed backend model usage.
+- Other missing deployment prices require **manual rates**. No price is inferred
+ from the supplied reference comparison table or substituted from another
+ model or provider. Cross-region Voice Live references are shown explicitly.
+ GPT Live uses a separately priced backend deployment regardless
+ of the hosting selector, plus the Azure OpenAI session-minute reference.
+- **Blank means unknown**, not zero. Missing model or speech prices block
+ complete totals until the required list rates are entered.
+
+Selection is an economic scenario, **not proof of model, transport, or Voice
+Agent availability in South India**. In particular, a BYOM native-audio
+scenario requires confirmation of API support and deployment prices.
+Provisioned-throughput and dedicated hosting charges are not modeled.
+
+References: [Voice Live tier mapping and usage](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/voice-live),
+[Speech list pricing](https://azure.microsoft.com/en-us/pricing/details/speech/),
+[Azure OpenAI pricing](https://azure.microsoft.com/en-us/pricing/details/azure-openai/),
+[Voice Agent billing](https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/voice-agent-pricing).
+
+To refresh the snapshot, rerun its two recorded OData filters with
+`currencyCode='USD'`, follow every `NextPageLink`, and review the exact meter
+names and units before replacing the file. Do not silently mix regional,
+Global, Data Zone, Batch, or fine-tuning meters.
+
+## Assumptions and formulas
+
+Defaults preserve the supplied **90-second call**, **4 user + 4 agent
+turns**, **45 seconds of speech each**, **20 audio tokens/sec** in both
+directions, **100% reused-context caching**, and **150 agent text tokens**
+for text-response pipelines. User transcript length was not supplied:
+the calculator explicitly assumes an editable **150 user text tokens**.
+Native output text and reasoning tokens default to zero and can be changed.
+These defaults are modeling inputs, not measured usage.
+
+Each call starts cold; the system prompt costs `P * input_rate` on its
+first turn. Its `P * (turns - 1)` reused tokens are charged at the selected
+cache hit rate. With equal turn lengths, each side's total conversation
+tokens are replayed `(turns - 1) / 2` times across later turns. Cache misses
+use the full input rate. Native mode reuses caller and assistant audio.
+Realtime-to-HD mode reuses caller audio and assistant text. Cascade and
+GPT Live's reasoning backend reuse user/assistant text. Hidden reasoning is
+billed as output but not replayed as visible transcript history.
+
+For each component, `list cost = quantity * USD_per_million / 1,000,000`.
+GPT Live voice sessions instead use `call_minutes * USD_per_minute`, in
+addition to the turn-based backend model estimate. Real delegation patterns
+can differ from this simplified assumption.
+
+Per-minute list prices divide by the full call duration, not just speaking
+time. Call-volume estimates multiply the per-call list price by the number
+of calls.
+
+The original reference totals are not regression targets: they do not supply
+all token prices or context-accounting details and may use different rates.
+This calculator recomputes costs transparently rather than fitting those totals.
+Actual caching eligibility, audio token rates, silence billing, tool calls,
+telephony, storage, network, tax, capacity charges, and model quality are not
+captured. In particular, the documented OpenAI input estimate is typically
+~10 audio tokens/sec; 20 is retained here because it was explicitly requested.
+
+## Checks
+
+```powershell
+node --test tools\cost_calculator\calculator.test.mjs
+```
diff --git a/tools/cost_calculator/app.mjs b/tools/cost_calculator/app.mjs
new file mode 100644
index 0000000..812cfdb
--- /dev/null
+++ b/tools/cost_calculator/app.mjs
@@ -0,0 +1,309 @@
+import { DEFAULTS, PROMPTS, MODELS, GPT_LIVE_METER, getRates, calculate } from "./calculator.mjs";
+
+const $ = id => document.getElementById(id);
+const form = $("assumptions");
+const money = value => value === null ? "Needs rate" : `$${value.toFixed(6)}`;
+const volumeMoney = value => value === null ? "Needs rate" :
+ value.toLocaleString("en-US", { style: "currency", currency: "USD" });
+const labels = {
+ textInput: "Text input", textCached: "Cached text input", textOutput: "Text output",
+ audioInput: "Native audio input", audioCached: "Cached native audio", audioOutput: "Native audio output",
+ speechInput: "Azure STT input", speechOutput: "Azure HD output", liveMinute: "GPT Live session ($/min)",
+};
+const pipelines = {
+ native: "Native speech-to-speech", hybrid: "Realtime text + Azure HD",
+ cascade: "Azure STT + LLM + Azure HD", live: "GPT Live + reasoning",
+};
+const overrides = new Map();
+let snapshot;
+let exportedRows = [];
+let selectedKey = "";
+
+function element(tag, text, className) {
+ const node = document.createElement(tag);
+ if (text !== undefined) node.textContent = text;
+ if (className) node.className = className;
+ return node;
+}
+
+function assumptions() {
+ const a = {};
+ for (const key of Object.keys(DEFAULTS)) {
+ const input = form.elements.namedItem(key);
+ if (input.value.trim() === "" || !input.validity.valid) throw new Error(`Enter a valid value for ${input.labels[0].textContent.trim()}.`);
+ a[key] = Number(input.value);
+ }
+ return a;
+}
+
+function effectiveHosting(pipeline) {
+ return pipeline === "live" ? "byom" : $("hosting").value;
+}
+
+function rateKey(model, pipeline) {
+ return `${pipeline === "live" ? "live" : "voice"}:${effectiveHosting(pipeline)}:${model.id}`;
+}
+
+function modelRates(model, pipeline) {
+ const book = getRates(snapshot, model, effectiveHosting(pipeline), pipeline);
+ return { ...book, rates: { ...book.rates, ...overrides.get(rateKey(model, pipeline)) } };
+}
+
+function populateModels(preferred) {
+ const pipeline = $("pipeline").value;
+ const available = MODELS.filter(m => ["native", "hybrid"].includes(pipeline) ? m.realtime : !m.realtime);
+ $("model").replaceChildren(...available.map(m => {
+ const option = element("option", m.name);
+ option.value = m.id;
+ return option;
+ }));
+ $("model").value = available.some(m => m.id === preferred) ? preferred :
+ pipeline === "live" ? "gpt-6-sol" : available[0].id;
+}
+
+function fillDefaults() {
+ for (const [key, value] of Object.entries(DEFAULTS)) form.elements.namedItem(key).value = value;
+}
+
+function showError(error) {
+ $("error").textContent = error.message;
+ $("error").hidden = false;
+ $("results").hidden = true;
+ $("export").disabled = true;
+}
+
+function rateEditor(model, pipeline, book) {
+ selectedKey = rateKey(model, pipeline);
+ const fields = ["textInput", "textCached", "textOutput"];
+ if (["native", "hybrid"].includes(pipeline)) fields.push("audioInput", "audioCached");
+ if (pipeline === "native") fields.push("audioOutput");
+ if (pipeline === "cascade") fields.push("speechInput");
+ if (["hybrid", "cascade"].includes(pipeline)) fields.push("speechOutput");
+ if (pipeline === "live") fields.push("liveMinute");
+ const edited = overrides.get(selectedKey) || {};
+ $("rates").replaceChildren(...fields.map(key => {
+ const label = element("label", labels[key]);
+ const input = element("input");
+ input.type = "number";
+ input.min = "0";
+ input.step = "any";
+ input.dataset.rate = key;
+ input.value = book.rates[key] ?? "";
+ input.placeholder = "Enter list price";
+ const source = book.sources[key];
+ const state = Object.hasOwn(edited, key) ? "User-supplied / edited" :
+ book.rates[key] === null ? "Unverified - manual rate required" :
+ source?.armRegionName === "centralindia" ? `${key === "speechOutput" ? "Azure HD" : "Voice Live"} / Central India reference` :
+ source?.processingScope === "Global" ? "Azure OpenAI / South India Global reference" : "Azure retail snapshot / South India";
+ label.append(input, element("small", state, "rate-status"));
+ return label;
+ }));
+ $("evidence").replaceChildren(...book.evidence.map(name => {
+ const meter = snapshot.meters.find(m => m.meterName === name);
+ return element("li", `${name}: $${meter.retailPrice} / ${meter.unitOfMeasure}; region ${meter.armRegionName || snapshot.region}${meter.processingScope ? ` / ${meter.processingScope}` : ""}; meter ${meter.meterId}`);
+ }));
+ const hosting = effectiveHosting(pipeline);
+ $("price-note").textContent = hosting === "managed" ?
+ "Managed models with a published tier use Central India Voice Live tier references because no South India tier meters were found. Azure HD also uses Central India standard-speech output. All token fields below are USD per 1M." :
+ "BYOM uses Azure OpenAI South India Global model prices plus Central India BYO speech input/output references; no South India Voice Live BYO meters were found. Azure HD remains Central India as requested. GPT-6 is explicitly GPT-6 Sol, short-context Standard pricing. Global processing does not guarantee India-only inference or deployment availability. All token fields below are USD per 1M.";
+ if (book.usesGlobalReference) {
+ $("price-note").textContent = "No published managed Voice Live tier was found for this model. This is an AOAI Global model-price reference plus Central India BYO speech, not a confirmed managed Voice Live tariff or availability claim. Published model rates use South India Global meters; missing inference prices remain manual. All token fields below are USD per 1M.";
+ }
+ if (pipeline === "live") $("price-note").textContent += " GPT Live uses Azure OpenAI's South India Global voice rate: $3/hour = $0.05/minute in this snapshot. Backend model usage is charged separately. GPT Live generates its own audio, so no Azure HD charge is added.";
+ if (model.oss && (hosting === "byom" || book.usesGlobalReference)) $("price-note").textContent += " No OSS cached-input meter was found; reused text is conservatively charged at full input price.";
+}
+
+function renderOverview(a, model, pipeline, result, book) {
+ $("scenario").textContent = `${model.name} / ${pipelines[pipeline]}`;
+ $("list-call").textContent = money(result.list);
+ $("list-minute").textContent = money(result.listPerMinute);
+ $("volume-list").textContent = volumeMoney(result.list === null ? null : result.list * a.calls);
+ $("volume-note").textContent = `${a.calls.toLocaleString("en-US")} calls / ${(a.calls * a.duration / 60).toLocaleString("en-US")} minutes`;
+ const badge = $("estimate-badge");
+ badge.textContent = result.list === null ? "Missing rates" :
+ book.usesGlobalReference ? "AOAI Global reference" : "List-price estimate";
+ badge.className = `badge${result.list === null ? "" : " good"}`;
+ $("summary").textContent = result.missing.length ?
+ `List estimate needs: ${result.missing.map(key => labels[key]).join(", ")}. Enter missing values in the price book below.` :
+ `${result.inputAudio.toLocaleString("en-US")} fresh caller audio tokens / ${result.outputAudio.toLocaleString("en-US")} generated speech tokens / ${a.turns} user + ${a.turns} agent turns.`;
+ $("model-note").textContent = pipeline === "live" ?
+ "Backend model + GPT Live voice use Azure OpenAI South India Global references ($0.05/minute for voice). Availability must be confirmed." :
+ book.usesGlobalReference ?
+ "Managed tariff unavailable: showing an AOAI Global model-price + Central India BYO speech reference instead. This does not establish managed model support." :
+ `Economic scenario only. ${$("hosting").value === "managed" ? `Central India managed-tier reference: ${model.tier || "unverified"}.` : "Model rates: South India Global. STT: Central India BYO reference."} Azure HD: Central India. Confirm model and transport availability.`;
+}
+
+function renderChart(a, pipeline, rates) {
+ const metric = $("metric").value;
+ const results = PROMPTS.map(prompt => ({ prompt, result: calculate({ ...a, prompt }, pipeline, rates) }));
+ const max = Math.max(...results.map(r => r.result[metric] ?? 0), 0.000001);
+ $("prompt-chart").replaceChildren(...results.map(({ prompt, result }) => {
+ const row = element("div", undefined, "bar-row");
+ const track = element("div", undefined, "bar-track");
+ const bar = element("div", undefined, "bar");
+ bar.style.width = `${(result[metric] ?? 0) / max * 100}%`;
+ if (result[metric] === null) bar.style.display = "none";
+ track.append(bar);
+ row.append(element("span", `${prompt.toLocaleString("en-US")} tokens`), track, element("strong", money(result[metric])));
+ return row;
+ }));
+}
+
+function scenarios() {
+ const defs = [{ model: MODELS.find(m => m.id === "gpt-6-sol"), pipeline: "live" }];
+ for (const model of MODELS) {
+ if (model.realtime) {
+ defs.push({ model, pipeline: "native" }, { model, pipeline: "hybrid" });
+ } else if (model.id !== "gpt-6-sol") {
+ defs.push({ model, pipeline: "cascade" });
+ }
+ }
+ const currentModel = MODELS.find(m => m.id === $("model").value);
+ const currentPipeline = $("pipeline").value;
+ if (!defs.some(s => s.model.id === currentModel.id && s.pipeline === currentPipeline)) {
+ defs.unshift({ model: currentModel, pipeline: currentPipeline });
+ }
+ return defs;
+}
+
+function renderComparison(a) {
+ const metric = $("metric").value;
+ const heading = element("tr");
+ heading.append(element("th", "Pipeline / model"), ...PROMPTS.map(p => element("th", `${p.toLocaleString("en-US")} tokens`)));
+ $("comparison").tHead.replaceChildren(heading);
+ exportedRows = [];
+ const body = scenarios().map(({ model, pipeline }) => {
+ const { rates, sources, usesGlobalReference } = modelRates(model, pipeline);
+ const modelMeter = sources.textInput || sources.audioInput;
+ const liveMeter = pipeline === "live" ? snapshot.meters.find(m => m.meterName === GPT_LIVE_METER) : null;
+ const row = element("tr");
+ const title = element("td", model.name);
+ title.append(element("small", `${pipelines[pipeline]} / ${usesGlobalReference ? "AOAI GLOBAL REFERENCE (managed tariff unavailable)" : effectiveHosting(pipeline).toUpperCase()}${modelMeter?.armRegionName === "southindia" ? " / model: South India Global reference" : ""}${liveMeter ? " / voice: South India Global reference" : ""}`));
+ row.append(title);
+ for (const prompt of PROMPTS) {
+ const result = calculate({ ...a, prompt }, pipeline, rates);
+ const cell = element("td", money(result[metric]), result[metric] === null ? "missing" : "");
+ cell.title = result.missing.length ? `Missing list rates: ${result.missing.map(k => labels[k]).join(", ")}` :
+ `List price: ${money(result.list)}/call; ${money(result.listPerMinute)}/minute`;
+ row.append(cell);
+ exportedRows.push({
+ model: model.id, pipeline, hosting: effectiveHosting(pipeline), region: snapshot.region,
+ pricing_basis: usesGlobalReference ? "aoai-global-model-plus-byo-speech-reference" :
+ effectiveHosting(pipeline) === "managed" ? "voice-live-tier-reference" : "byom",
+ prompt_tokens: prompt, list_per_call: result.list,
+ list_per_minute: result.listPerMinute, missing_list_rates: result.missing.join("; "),
+ price_snapshot: snapshot.retrievedAt,
+ model_rate_region: modelMeter ? modelMeter.armRegionName || snapshot.region : "",
+ model_rate_scope: modelMeter?.processingScope || "",
+ model_rate_meter: modelMeter?.meterId || "",
+ speech_input_region: pipeline === "cascade" ? sources.speechInput?.armRegionName || "" : "",
+ speech_input_meter: pipeline === "cascade" ? sources.speechInput?.meterId || "" : "",
+ hd_voice_region: ["hybrid", "cascade"].includes(pipeline) ? sources.speechOutput?.armRegionName || "" : "",
+ hd_voice_meter: ["hybrid", "cascade"].includes(pipeline) ? sources.speechOutput?.meterId || "" : "",
+ live_session_region: liveMeter?.armRegionName || "",
+ live_session_scope: liveMeter?.processingScope || "",
+ live_session_meter: liveMeter?.meterId || "",
+ live_session_per_minute: pipeline === "live" ? rates.liveMinute : null,
+ rate_overrides: JSON.stringify(overrides.get(rateKey(model, pipeline)) || {}),
+ assumptions: JSON.stringify({ ...a, prompt }),
+ });
+ }
+ return row;
+ });
+ $("comparison").tBodies[0].replaceChildren(...body);
+}
+
+function renderBreakdown(result) {
+ $("breakdown").tBodies[0].replaceChildren(...result.rows.map(r => {
+ const row = element("tr");
+ row.append(element("td", r.label),
+ element("td", `${r.quantity.toLocaleString("en-US", { maximumFractionDigits: 3 })}${r.unit === "minutes" ? " min" : ""}`),
+ element("td", r.rate === null ? "Needs rate" : `$${r.rate}`),
+ element("td", money(r.list)));
+ return row;
+ }));
+}
+
+function update(rebuildRates = false) {
+ try {
+ const a = assumptions();
+ const model = MODELS.find(m => m.id === $("model").value);
+ const pipeline = $("pipeline").value;
+ const book = modelRates(model, pipeline);
+ if (rebuildRates || selectedKey !== rateKey(model, pipeline)) rateEditor(model, pipeline, book);
+ for (const input of $("rates").querySelectorAll("input")) {
+ if (!input.validity.valid) throw new Error(`Enter a non-negative price for ${labels[input.dataset.rate]}, or leave it blank for unknown.`);
+ }
+ const result = calculate(a, pipeline, book.rates);
+ renderOverview(a, model, pipeline, result, book);
+ renderChart(a, pipeline, book.rates);
+ renderComparison(a);
+ renderBreakdown(result);
+ $("error").hidden = true;
+ $("results").hidden = false;
+ $("export").disabled = false;
+ } catch (error) {
+ showError(error);
+ }
+}
+
+form.addEventListener("submit", event => event.preventDefault());
+form.addEventListener("input", event => {
+ if (event.target.tagName === "SELECT") return;
+ update();
+});
+$("hosting").addEventListener("change", () => update(true));
+$("pipeline").addEventListener("change", () => {
+ populateModels($("model").value);
+ update(true);
+});
+$("model").addEventListener("change", () => update(true));
+$("metric").addEventListener("change", () => update());
+$("rates").addEventListener("input", event => {
+ const input = event.target;
+ if (!input.dataset.rate) return;
+ if (!input.validity.valid) {
+ showError(new Error(`Enter a non-negative price for ${labels[input.dataset.rate]}, or leave it blank for unknown.`));
+ return;
+ }
+ const values = { ...overrides.get(selectedKey), [input.dataset.rate]: input.value.trim() === "" ? null : Number(input.value) };
+ overrides.set(selectedKey, values);
+ input.parentElement.querySelector(".rate-status").textContent = "User-supplied / edited";
+ update();
+});
+$("reset").addEventListener("click", () => {
+ fillDefaults();
+ overrides.clear();
+ $("hosting").value = "managed";
+ $("pipeline").value = "native";
+ $("metric").value = "list";
+ populateModels();
+ update(true);
+});
+$("export").addEventListener("click", () => {
+ if ($("results").hidden || !exportedRows.length) return;
+ const columns = Object.keys(exportedRows[0]);
+ const quote = value => `"${String(value ?? "").replaceAll('"', '""')}"`;
+ const csv = [columns, ...exportedRows.map(row => columns.map(c => row[c]))].map(row => row.map(quote).join(",")).join("\r\n");
+ const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
+ const link = element("a");
+ link.href = url;
+ link.download = "voice-agent-costs-southindia.csv";
+ link.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+});
+
+fillDefaults();
+populateModels();
+try {
+ const response = await fetch("./prices.json");
+ if (!response.ok) throw new Error(`Price snapshot could not be loaded (HTTP ${response.status}).`);
+ snapshot = await response.json();
+ if (snapshot.region !== "southindia" || snapshot.currency !== "USD" || !Array.isArray(snapshot.meters)) {
+ throw new Error("Price snapshot must contain South India USD reference prices.");
+ }
+ $("snapshot-date").textContent = `Price snapshot / ${snapshot.retrievedAt}`;
+ update(true);
+} catch (error) {
+ showError(new Error(`Calculator could not start: ${error.message} Serve this directory over HTTP, not file://.`));
+}
diff --git a/tools/cost_calculator/calculator.mjs b/tools/cost_calculator/calculator.mjs
new file mode 100644
index 0000000..124c550
--- /dev/null
+++ b/tools/cost_calculator/calculator.mjs
@@ -0,0 +1,184 @@
+export const DEFAULTS = Object.freeze({
+ duration: 90, turns: 4, humanSeconds: 45, agentSeconds: 45,
+ inputAudioRate: 20, outputAudioRate: 20,
+ userText: 150, agentText: 150, nativeText: 0, reasoning: 0,
+ cachePercent: 100, prompt: 5000, calls: 10000,
+});
+
+export const PROMPTS = [0, 1000, 5000, 10000, 20000];
+export const SOURCE_DOC = "https://learn.microsoft.com/en-us/azure/ai-services/speech-service/voice-live";
+export const GPT_LIVE_METER = "Live 1 Gl Unit";
+
+export const MODELS = [
+ { id: "gpt-realtime-2.1", name: "GPT Realtime 2.1", tier: "Pro", realtime: true, output2: true },
+ { id: "gpt-realtime-2.1-mini", name: "GPT Realtime 2.1 mini", tier: "Std", realtime: true },
+ { id: "gpt-realtime", name: "GPT Realtime 1.0 (gpt-realtime)", tier: "Pro", realtime: true },
+ { id: "gpt-realtime-1.5", name: "GPT Realtime 1.5", tier: "Pro", realtime: true },
+ { id: "gpt-4.1", name: "GPT-4.1", tier: "Pro" },
+ { id: "gpt-4.1-mini", name: "GPT-4.1 mini", tier: "Std" },
+ { id: "gpt-4.1-nano", name: "GPT-4.1 nano", tier: "Lite" },
+ { id: "gpt-5.4", name: "GPT-5.4", tier: "Pro", byomPrefix: "5.4" },
+ { id: "gpt-5.4-mini", name: "GPT-5.4 mini", byomPrefix: "5.4 mini" },
+ { id: "gpt-5.4-nano", name: "GPT-5.4 nano", byomPrefix: "5.4 nano" },
+ { id: "gpt-5.2", name: "GPT-5.2", tier: "Pro", byomPrefix: "GPT 5.2" },
+ { id: "gpt-5.1", name: "GPT-5.1", tier: "Pro", byomPrefix: "GPT 5.1" },
+ { id: "gpt-oss-120b", name: "GPT OSS 120B", oss: true },
+ { id: "gpt-6-sol", name: "GPT-6 Sol" },
+];
+
+export function validateAssumptions(a) {
+ for (const key of Object.keys(DEFAULTS)) {
+ if (typeof a[key] !== "number" || !Number.isFinite(a[key]) || a[key] < 0) {
+ throw new Error(`${key} must be a finite, non-negative number.`);
+ }
+ }
+ if (a.duration === 0) throw new Error("Call duration must be greater than zero.");
+ if (!Number.isInteger(a.turns) || a.turns < 1) throw new Error("Turns must be a positive integer.");
+ if (!Number.isInteger(a.calls)) throw new Error("Call volume must be an integer.");
+ if (a.humanSeconds + a.agentSeconds > a.duration) {
+ throw new Error("Human + agent speaking time cannot exceed call duration in this turn-based estimate.");
+ }
+ if (a.cachePercent > 100) throw new Error("Cache percentage must be between 0 and 100.");
+}
+
+export function meterRate(snapshot, name, unit = "tokens") {
+ const matches = snapshot.meters.filter(m => m.meterName === name);
+ if (matches.length !== 1) throw new Error(`Expected exactly one retail meter: ${name}`);
+ const meter = matches[0];
+ const units = { tokens: { "1K": 1000, "1M": 1 }, minutes: { "1 Hour": 1 / 60, "1 Minute": 1 } };
+ const factor = units[unit]?.[meter.unitOfMeasure];
+ if (!factor || !Number.isFinite(meter.retailPrice) || meter.retailPrice < 0) {
+ throw new Error(`Unsupported retail rate or unit: ${name}`);
+ }
+ return meter.retailPrice * factor;
+}
+
+export function getRates(snapshot, model, hosting, pipeline = "cascade") {
+ const rates = { textInput: null, textCached: null, textOutput: null,
+ audioInput: null, audioCached: null, audioOutput: null, speechInput: null, speechOutput: null,
+ liveMinute: null };
+ const evidence = [];
+ const sources = {};
+ const usesGlobalReference = hosting === "managed" && !model.tier;
+ function read(key, name, unit = "tokens") {
+ rates[key] = meterRate(snapshot, name, unit);
+ sources[key] = snapshot.meters.find(m => m.meterName === name);
+ evidence.push(name);
+ }
+ if (hosting === "managed" && model.tier) {
+ const prefix = `Voice Live API ${model.tier}`;
+ read("textInput", `${prefix} - LLM Text Input Tokens`);
+ read("textCached", `${prefix} - LLM Text Cached Tokens`);
+ read("textOutput", `${prefix} - LLM Text Output${model.output2 ? " 2" : ""} Tokens`);
+ read("speechInput", `${prefix} - Standard Speech Audio Input Tokens`);
+ read("speechOutput", `${prefix} - Standard Speech Audio ${model.tier === "Std" ? "Outpt" : "Output"} Tokens`);
+ if (model.realtime) {
+ read("audioInput", `${prefix} - LLM Audio Input Tokens`);
+ read("audioCached", `${prefix}${model.tier === "Std" ? "-" : " -"} LLM Audio Cached Tokens`);
+ read("audioOutput", `${prefix}${model.tier === "Std" ? "-" : " -"} LLM Audio Output Tokens`);
+ }
+ } else if (hosting === "byom" || usesGlobalReference) {
+ read("speechInput", "Voice Live BYO Standard Speech Audio Input Tokens");
+ read("speechOutput", "Voice Live BYO Standard Speech Audio Output Tokens");
+ if (model.realtime) {
+ if (model.id.startsWith("gpt-realtime-2.1")) {
+ for (const [kind, label] of [["text", "Text"], ["audio", "Audio"]]) {
+ read(`${kind}Input`, `${model.id} ${label} inp Gl 1M Tokens`);
+ read(`${kind}Cached`, `${model.id} ${label} cd inp Gl 1M Tokens`);
+ read(`${kind}Output`, `${model.id} ${label} opt Gl 1M Tokens`);
+ }
+ } else if (model.id === "gpt-realtime-1.5") {
+ for (const [kind, label] of [["text", "txt"], ["audio", "aud"]]) {
+ read(`${kind}Input`, `gpt rt 1.5 ${label} inp Gl 1M Tokens`);
+ read(`${kind}Cached`, `gpt rt 1.5 ${label} cd inp Gl 1M Tokens`);
+ read(`${kind}Output`, `gpt rt 1.5 ${label} opt Gl 1M Tokens`);
+ }
+ } else {
+ for (const [kind, label] of [["text", "txt"], ["audio", "aud"]]) {
+ read(`${kind}Input`, `gpt rt ${label} 0828 Inp glbl Tokens`);
+ read(`${kind}Cached`, `gpt rt ${label} 0828 cchd Inp glbl Tokens`);
+ read(`${kind}Output`, `gpt rt ${label} 0828 Outp glbl Tokens`);
+ }
+ }
+ } else if (model.id.startsWith("gpt-4.1")) {
+ const p = model.id.replaceAll("-", " ");
+ read("textInput", `${p} Inp glbl Tokens`);
+ read("textCached", `${p} cached Inp glbl Tokens`);
+ read("textOutput", `${p} Outp glbl Tokens`);
+ } else if (model.oss) {
+ read("textInput", "gpt-oss-120B Inp glbl Tokens");
+ read("textOutput", "gpt-oss-120B Outp glbl Tokens");
+ // No cache meter is published in the regional snapshot. Use full input
+ // pricing for reused tokens rather than inventing a cache discount.
+ rates.textCached = rates.textInput;
+ sources.textCached = sources.textInput;
+ } else if (model.id === "gpt-6-sol") {
+ read("textInput", "6-sol ShortCo Inp Std Gl 1M Tokens");
+ read("textCached", "6-sol ShortCo Cd Inp Std Gl 1M Tokens");
+ read("textOutput", "6-sol ShortCo Opt Std Gl 1M Tokens");
+ } else if (model.byomPrefix) {
+ const p = model.byomPrefix;
+ read("textInput", `${p} ${p.startsWith("5.4 ") ? "Inp" : "inp"} Gl 1M Tokens`);
+ read("textCached", `${p} ${p.startsWith("5.4 ") ? "cd Inp" : "cd inp"} Gl 1M Tokens`);
+ read("textOutput", `${p} ${p.startsWith("5.4 ") ? "Opt" : "opt"} Gl 1M Tokens`);
+ }
+ }
+ if (pipeline === "live") read("liveMinute", GPT_LIVE_METER, "minutes");
+ return { rates, evidence, sources, usesGlobalReference };
+}
+
+export function calculate(a, pipeline, rates) {
+ validateAssumptions(a);
+ if (!["native", "hybrid", "cascade", "live"].includes(pipeline)) throw new Error("Unknown pipeline.");
+ const rows = [];
+ const missing = new Set();
+ const reuse = (a.turns - 1) / 2;
+ const hit = a.cachePercent / 100;
+ const inputAudio = a.humanSeconds * a.inputAudioRate;
+ const outputAudio = a.agentSeconds * a.outputAudioRate;
+ function add(label, quantity, key, unit = "tokens") {
+ if (!Number.isFinite(quantity)) throw new Error(`Token quantity is too large: ${label}`);
+ if (quantity === 0) return;
+ const rate = rates[key];
+ if (rate !== null && (!Number.isFinite(rate) || rate < 0)) throw new Error(`Invalid rate: ${key}`);
+ if (rate == null) missing.add(key);
+ const divisor = unit === "minutes" ? 1 : 1e6;
+ const list = rate == null ? null : quantity * rate / divisor;
+ if (list !== null && !Number.isFinite(list)) {
+ throw new Error(`Cost is too large: ${label}`);
+ }
+ rows.push({ label, quantity, key, unit, rate, list });
+ }
+ function input(label, fresh, reused, normal, cached) {
+ add(`${label} / fresh + cache misses`, fresh + reused * (1 - hit), normal);
+ add(`${label} / cached`, reused * hit, cached);
+ }
+ input("System prompt", a.prompt, a.prompt * (a.turns - 1), "textInput", "textCached");
+ if (pipeline === "native" || pipeline === "hybrid") {
+ input("Caller audio", inputAudio, inputAudio * reuse, "audioInput", "audioCached");
+ if (pipeline === "native") {
+ input("Previous agent audio", 0, outputAudio * reuse, "audioInput", "audioCached");
+ add("Native audio output", outputAudio, "audioOutput");
+ input("Previous output text", 0, a.nativeText * reuse, "textInput", "textCached");
+ add("Native output text (optional)", a.nativeText, "textOutput");
+ } else {
+ input("Previous agent text", 0, a.agentText * reuse, "textInput", "textCached");
+ add("Agent text + reasoning output", a.agentText + a.reasoning, "textOutput");
+ add("Azure HD speech", outputAudio, "speechOutput");
+ }
+ } else {
+ input("User transcript", a.userText, a.userText * reuse, "textInput", "textCached");
+ input("Previous agent text", 0, a.agentText * reuse, "textInput", "textCached");
+ add("Agent text + reasoning output", a.agentText + a.reasoning, "textOutput");
+ if (pipeline === "cascade") {
+ add("Azure STT", inputAudio, "speechInput");
+ add("Azure HD speech", outputAudio, "speechOutput");
+ } else {
+ add("GPT Live voice session", a.duration / 60, "liveMinute", "minutes");
+ }
+ }
+ const listComplete = rows.every(r => r.list !== null);
+ const list = listComplete ? rows.reduce((sum, r) => sum + r.list, 0) : null;
+ return { rows, missing: [...missing], list, inputAudio, outputAudio,
+ listPerMinute: list === null ? null : list / (a.duration / 60) };
+}
diff --git a/tools/cost_calculator/calculator.test.mjs b/tools/cost_calculator/calculator.test.mjs
new file mode 100644
index 0000000..5844653
--- /dev/null
+++ b/tools/cost_calculator/calculator.test.mjs
@@ -0,0 +1,215 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import { DEFAULTS, MODELS, PROMPTS, GPT_LIVE_METER, getRates, calculate, meterRate } from "./calculator.mjs";
+
+const snapshot = JSON.parse((await readFile(new URL("./prices.json", import.meta.url), "utf8")).replace(/^\uFEFF/, ""));
+const model = id => MODELS.find(m => m.id === id);
+const book = (id, hosting = "managed", pipeline = "cascade") => getRates(snapshot, model(id), hosting, pipeline).rates;
+const close = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-12, `${actual} != ${expected}`);
+
+test("normalizes Azure 1K and 1M meters, retains exact rates", () => {
+ close(book("gpt-realtime-2.1").textInput, 4);
+ close(book("gpt-realtime-2.1").textCached, 0.4);
+ close(book("gpt-realtime-2.1").textOutput, 24);
+ close(book("gpt-realtime").textOutput, 16);
+ close(book("gpt-realtime-2.1-mini").audioCached, 0.33);
+ close(book("gpt-5.4", "byom").textInput, 2.5);
+ close(book("gpt-5.4-mini", "byom").textOutput, 4.5);
+ close(book("gpt-5.4-nano", "byom").textCached, 0.02);
+ assert.throws(() => meterRate(snapshot, "not a meter"), /exactly one/);
+ assert.throws(() => meterRate({ meters: [{ meterName: "bad", unitOfMeasure: "Hour", retailPrice: 1 }] }, "bad"), /Unsupported/);
+});
+
+test("every known model/hosting combination resolves without a missing meter error", () => {
+ for (const m of MODELS) for (const hosting of ["managed", "byom"]) getRates(snapshot, m, hosting);
+});
+
+test("BYOM models are prefilled from South India Global while speech remains Central India", () => {
+ assert.equal(snapshot.region, "southindia");
+ assert.equal(snapshot.voiceLiveRegion, "centralindia");
+ for (const m of MODELS) {
+ const { rates, sources } = getRates(snapshot, m, "byom");
+ for (const key of ["textInput", "textCached", "textOutput", ...(m.realtime ? ["audioInput", "audioCached", "audioOutput"] : [])]) {
+ assert.notEqual(rates[key], null, `${m.id} ${key} should be prefilled`);
+ assert.equal(sources[key].armRegionName, "southindia");
+ assert.equal(sources[key].processingScope, "Global");
+ assert.doesNotMatch(sources[key].meterName, /Batch|FT|Flex| PP /);
+ }
+ for (const key of ["speechInput", "speechOutput"]) assert.equal(sources[key].armRegionName, "centralindia");
+ }
+});
+
+test("South India GPT-4.1 and realtime prices do not reuse managed tier rates", () => {
+ const rates = book("gpt-4.1", "byom");
+ close(rates.textInput, 2);
+ close(rates.textCached, 0.5);
+ close(rates.textOutput, 8);
+ close(book("gpt-4.1-mini", "byom").textInput, 0.4);
+ close(book("gpt-4.1-nano", "byom").textOutput, 0.4);
+ close(book("gpt-realtime-2.1-mini", "byom").audioInput, 10);
+ close(book("gpt-realtime-2.1-mini", "byom").textCached, 0.06);
+ close(book("gpt-realtime", "byom").audioOutput, 64);
+ const result = calculate({ ...DEFAULTS, prompt: 0 }, "cascade", rates);
+ close(result.list, (150 * 2 + 450 * 0.5 + 150 * 8 + 900 * (12.5 + 23)) / 1e6);
+});
+
+test("unverified managed tiers use explicit Global model references, not invented tier rates", () => {
+ for (const id of ["gpt-5.4-mini", "gpt-5.4-nano", "gpt-oss-120b", "gpt-6-sol"]) {
+ const reference = getRates(snapshot, model(id), "managed");
+ assert.equal(reference.usesGlobalReference, true);
+ assert.deepEqual(reference.rates, book(id, "byom"));
+ assert.equal(reference.sources.textInput.processingScope, "Global");
+ assert.equal(reference.sources.speechOutput.armRegionName, "centralindia");
+ assert.notEqual(calculate(DEFAULTS, "cascade", reference.rates).list, null);
+ }
+ const tier = getRates(snapshot, model("gpt-4.1"), "managed");
+ assert.equal(tier.usesGlobalReference, false);
+ close(tier.rates.textInput, 4);
+});
+
+test("all selectable models have priced scenarios across both hosting modes", () => {
+ for (const hosting of ["managed", "byom"]) for (const m of MODELS) {
+ const pipeline = m.realtime ? "native" : "cascade";
+ const result = calculate(DEFAULTS, pipeline, book(m.id, hosting));
+ assert.notEqual(result.list, null, `${hosting} / ${m.id}`);
+ assert.deepEqual(result.missing, []);
+ }
+});
+
+test("default native call counts fresh audio and cached history without speech duplication", () => {
+ const result = calculate({ ...DEFAULTS, prompt: 0 }, "native", book("gpt-realtime-2.1"));
+ assert.equal(result.inputAudio, 900);
+ assert.equal(result.outputAudio, 900);
+ close(result.list, (900 * 32 + 900 * 64 + 2700 * 0.4) / 1e6);
+ assert.ok(result.rows.every(r => !r.label.includes("Azure")));
+});
+
+test("system prompt is full price once, reused across the remaining turns", () => {
+ const rates = book("gpt-realtime-2.1");
+ const base = calculate({ ...DEFAULTS, prompt: 0 }, "native", rates);
+ const full = calculate(DEFAULTS, "native", rates);
+ close(full.list - base.list, 5000 * (4 + 3 * 0.4) / 1e6);
+ const noCache = calculate({ ...DEFAULTS, cachePercent: 0 }, "native", rates);
+ close(noCache.list, (20000 * 4 + 3600 * 32 + 900 * 64) / 1e6);
+ const halfCache = calculate({ ...DEFAULTS, cachePercent: 50 }, "native", rates);
+ close(halfCache.list, (noCache.list + full.list) / 2);
+});
+
+test("single turn does not charge replayed context", () => {
+ const result = calculate({ ...DEFAULTS, turns: 1 }, "hybrid", book("gpt-realtime-2.1"));
+ assert.ok(result.rows.every(r => !r.label.includes("/ cached")));
+ close(result.list, (5000 * 4 + 900 * 32 + 150 * 24 + 900 * 31) / 1e6);
+});
+
+test("hybrid uses 150 text output tokens and HD list price, no native output audio charge", () => {
+ const result = calculate({ ...DEFAULTS, prompt: 0 }, "hybrid", book("gpt-realtime-2.1"));
+ const llm = (900 * 32 + 1350 * 0.4 + 225 * 0.4 + 150 * 24) / 1e6;
+ close(result.list, llm + 900 * 31 / 1e6);
+ assert.ok(result.rows.every(r => r.key !== "audioOutput"));
+});
+
+test("BYOM uses BYO speech meters, not managed tier prices", () => {
+ const rates = book("gpt-5.4", "byom");
+ close(rates.speechInput, 12.5);
+ close(rates.speechOutput, 23);
+ close(rates.textOutput, 15);
+ const result = calculate({ ...DEFAULTS, prompt: 0 }, "cascade", rates);
+ const llm = (150 * 2.5 + 450 * 0.25 + 150 * 15) / 1e6;
+ close(result.list, llm + 900 * (12.5 + 23) / 1e6);
+ close(result.listPerMinute, result.list / 1.5);
+});
+
+test("OSS without published cached pricing charges full input instead of free caching", () => {
+ const rates = book("gpt-oss-120b", "byom");
+ close(rates.textInput, 0.15);
+ close(rates.textCached, 0.15);
+ close(rates.textOutput, 0.6);
+});
+
+test("unknown prices block totals, not silently treated as zero", () => {
+ const rates = { ...book("gpt-4.1", "byom"), textInput: null };
+ const result = calculate(DEFAULTS, "cascade", rates);
+ assert.equal(result.list, null);
+ assert.equal(result.listPerMinute, null);
+ assert.ok(result.missing.includes("textInput"));
+ const unknownSpeech = calculate(DEFAULTS, "cascade", { ...book("gpt-5.4", "byom"), speechOutput: null });
+ assert.equal(unknownSpeech.list, null);
+ assert.equal(unknownSpeech.listPerMinute, null);
+ assert.ok(unknownSpeech.missing.includes("speechOutput"));
+});
+
+test("GPT Live hourly AOAI meter converts to a per-minute reference, not a token rate", () => {
+ const meter = snapshot.meters.find(m => m.meterName === GPT_LIVE_METER);
+ assert.equal(meter.armRegionName, "southindia");
+ assert.equal(meter.processingScope, "Global");
+ close(meterRate(snapshot, GPT_LIVE_METER, "minutes"), 0.05);
+ assert.throws(() => meterRate(snapshot, GPT_LIVE_METER), /Unsupported/);
+ assert.throws(() => meterRate(snapshot, "Voice Live API Pro - LLM Text Input Tokens", "minutes"), /Unsupported/);
+ assert.equal(book("gpt-5.4", "byom").liveMinute, null);
+});
+
+test("GPT Live uses South India GPT-6 Sol backend and AOAI voice prices", () => {
+ const rates = book("gpt-6-sol", "byom", "live");
+ close(rates.liveMinute, 0.05);
+ close(rates.textInput, 2);
+ close(rates.textCached, 0.2);
+ close(rates.textOutput, 10);
+ const result = calculate({ ...DEFAULTS, prompt: 0 }, "live", rates);
+ close(result.list, (150 * 2 + 450 * 0.2 + 150 * 10) / 1e6 + 1.5 * 0.05);
+ assert.ok(result.rows.every(r => !r.label.includes("Azure")));
+});
+
+test("priced GPT Live backend adds session minutes without whole-minute rounding or speech duplication", () => {
+ const result = calculate({ ...DEFAULTS, prompt: 0 }, "live", book("gpt-5.4", "byom", "live"));
+ close(result.list, (150 * 2.5 + 450 * 0.25 + 150 * 15) / 1e6 + 1.5 * 0.05);
+ const session = result.rows.find(r => r.key === "liveMinute");
+ close(session.list, 0.075);
+ assert.equal(session.unit, "minutes");
+ assert.ok(result.rows.every(r => !["speechInput", "speechOutput", "audioInput", "audioOutput"].includes(r.key)));
+ const missingSession = calculate(DEFAULTS, "live", { ...book("gpt-5.4", "byom", "live"), liveMinute: null });
+ assert.deepEqual(missingSession.missing, ["liveMinute"]);
+ assert.equal(missingSession.list, null);
+});
+
+test("reasoning output is billed but not added to visible cached history", () => {
+ const base = calculate(DEFAULTS, "cascade", book("gpt-4.1"));
+ const extra = calculate({ ...DEFAULTS, reasoning: 1000 }, "cascade", book("gpt-4.1"));
+ close(extra.list - base.list, 1000 * 16 / 1e6);
+});
+
+test("results and components expose only list-price fields", () => {
+ const result = calculate(DEFAULTS, "cascade", book("gpt-4.1"));
+ assert.deepEqual(Object.keys(result).sort(), ["inputAudio", "list", "listPerMinute", "missing", "outputAudio", "rows"]);
+ for (const row of result.rows) {
+ assert.deepEqual(Object.keys(row).sort(), ["key", "label", "list", "quantity", "rate", "unit"]);
+ }
+ close(result.list, result.rows.reduce((sum, row) => sum + row.list, 0));
+ close(result.listPerMinute, result.list / 1.5);
+});
+
+test("invalid assumptions and negative rates are rejected", () => {
+ for (const changes of [{ duration: 0 }, { turns: 0 }, { turns: 1.5 }, { humanSeconds: 91 },
+ { agentSeconds: -1 }, { prompt: NaN }, { cachePercent: 101 }, { calls: 1.5 }, { inputAudioRate: Infinity }]) {
+ assert.throws(() => calculate({ ...DEFAULTS, ...changes }, "native", book("gpt-realtime")));
+ }
+ assert.throws(() => calculate(DEFAULTS, "native", { ...book("gpt-realtime"), audioInput: -1 }), /Invalid rate/);
+ assert.throws(() => calculate({ ...DEFAULTS, prompt: Number.MAX_VALUE }, "native", book("gpt-realtime")), /too large/);
+});
+
+test("zero-priced input is explicit and unused missing rates do not block a total", () => {
+ const rates = { ...book("gpt-realtime"), textInput: null, textCached: null, audioOutput: 0 };
+ const result = calculate({ ...DEFAULTS, prompt: 0 }, "native", rates);
+ assert.deepEqual(result.missing, []);
+ close(result.list, (900 * 32 + 2700 * 0.4) / 1e6);
+});
+
+test("all five prompt sizes grow monotonically for every priced model", () => {
+ assert.deepEqual(PROMPTS, [0, 1000, 5000, 10000, 20000]);
+ for (const m of MODELS) for (const hosting of ["managed", "byom"]) {
+ const costs = PROMPTS.map(prompt => calculate({ ...DEFAULTS, prompt }, m.realtime ? "native" : "cascade", book(m.id, hosting)).list);
+ if (costs.every(c => c !== null)) {
+ for (let i = 1; i < costs.length; i++) assert.ok(costs[i] >= costs[i - 1]);
+ }
+ }
+});
diff --git a/tools/cost_calculator/index.html b/tools/cost_calculator/index.html
new file mode 100644
index 0000000..4bbe8da
--- /dev/null
+++ b/tools/cost_calculator/index.html
@@ -0,0 +1,105 @@
+
+
+
+
+
+ Voice Agent Cost Lab
+
+
+
+
+
+
VOICE AGENT / ECONOMICS
Cost lab.
+
Compare voice pipelines. See what each turn really costs.
+
SOUTH INDIA USD / pay-as-you-goLoading price snapshot...HD voice & missing Voice Live meters: Central India references
+
+
+
+
+
+
+
+
YOUR SELECTED SCENARIO
Calculating...
Estimate
+
+ List price / call--Includes replayed context
+ List price / minute--Based on full call duration
+ List price at your volume--
+
+
+
+
+
THE LONG-PROMPT TAX
System prompt sensitivity
+
+
+
+
Same conversation, different prompt sizes. All values use list prices and the selected per-call or per-minute view.
+
+
+
SIDE BY SIDE
Pipeline comparison
+
Uses your assumptions and each model's own rates. Without a published managed tier, rows are explicitly labeled AOAI Global model-price + BYO speech references, not managed tariffs. Edits to the selected model apply to its rows. Missing prices never become zero.
+
+
+
+ FOLLOW THE TOKENS
Selected call breakdown
+
Component
Quantity
List rate*
List / call
+
*USD per million tokens, except GPT Live in USD/minute.
Blank = unknown, never free. Edits are user-supplied USD rates for this model and hosting mode, kept only in this page's memory. Clearing a verified rate marks it unknown. No Azure API is called at runtime.
+ Retail meter provenance
+
+
+
+
Read the estimate, not an invoice
+
Each call starts with an uncached prompt. On later turns the prompt and prior conversation are reused at your cache-hit percentage. With equally sized turns, prior caller/agent tokens are each replayed (turns - 1) / 2 times. Cache misses use full input rates.
+
Native mode reuses audio history; text-response mode reuses caller audio and agent text; cascade and reasoning backends reuse text. Only newly generated speech is synthesized. Reasoning tokens are charged as text output, not retained as visible conversation history.
+
100% caching is an idealized assumption, not guaranteed prefix-cache eligibility. The supplied 20 input audio tokens/sec is preserved even though the public guide typically estimates ~10 for OpenAI input audio. Actual tokens, silence billing, tools, storage, telephony, hosting capacity, tax, and network charges can change the result and are excluded.
+
Model selection is for economic modeling, not a guarantee of South India deployment availability or supported BYOM audio connections. South India Global is the reference for BYOM models and GPT Live. No South India Voice Live managed-tier or BYO speech meters were found, so those use approved Central India references. Azure HD speech output also deliberately uses Central India rates. GPT-6 is explicitly GPT-6 Sol, short-context Standard pricing. GPT Live voice uses the South India Global rate ($3/hour = $0.05/minute); backend model usage is additional and no Azure HD charge is added. GPT Live's delegation frequency and backend context differ in practice; this estimate uses the same turn-based backend assumptions and a session-minute rate.
+
If a model has no verified managed tier, its economic reference combines AOAI Global model rates with BYO speech rates and is labeled accordingly. It is not a managed Voice Live price or support claim.