From 92b536dacad456b9a890b61d5b4b294a4aa71020 Mon Sep 17 00:00:00 2001 From: "Sheng Zhao (SPEECH)" Date: Thu, 22 Oct 2026 14:39:58 +0800 Subject: [PATCH] feat: add Voice Agent cost calculator and Pages deployment Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/cost-calculator-pages.yml | 68 ++ samples/README.md | 1 + tools/cost_calculator/README.md | 166 +++ tools/cost_calculator/app.mjs | 309 ++++++ tools/cost_calculator/calculator.mjs | 184 ++++ tools/cost_calculator/calculator.test.mjs | 215 ++++ tools/cost_calculator/index.html | 105 ++ tools/cost_calculator/prices.json | 1037 +++++++++++++++++++ tools/cost_calculator/server.mjs | 34 + tools/cost_calculator/styles.css | 3 + 10 files changed, 2122 insertions(+) create mode 100644 .github/workflows/cost-calculator-pages.yml create mode 100644 tools/cost_calculator/README.md create mode 100644 tools/cost_calculator/app.mjs create mode 100644 tools/cost_calculator/calculator.mjs create mode 100644 tools/cost_calculator/calculator.test.mjs create mode 100644 tools/cost_calculator/index.html create mode 100644 tools/cost_calculator/prices.json create mode 100644 tools/cost_calculator/server.mjs create mode 100644 tools/cost_calculator/styles.css 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

+
ComponentQuantityList rate*List / call
+

*USD per million tokens, except GPT Live in USD/minute.

+
+
+
+
+ Price book & editable deployment rates +

+
+

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.

    +

    Azure Speech pricing / Azure OpenAI pricing / Model tiers & metering / Voice Agent billing

    +
    +
    +
    + + + diff --git a/tools/cost_calculator/prices.json b/tools/cost_calculator/prices.json new file mode 100644 index 0000000..afe1f00 --- /dev/null +++ b/tools/cost_calculator/prices.json @@ -0,0 +1,1037 @@ +{ + "region": "southindia", + "voiceLiveRegion": "centralindia", + "currency": "USD", + "retrievedAt": "2026-10-22", + "source": "https://prices.azure.com/api/retail/prices", + "filters": [ + "armRegionName eq 'centralindia' and productName eq 'Azure Speech' and priceType eq 'Consumption'", + "armRegionName eq 'southindia' and serviceName eq 'Foundry Models' and priceType eq 'Consumption'" + ], + "meters": [ + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Lite - Custom Speech Audio Cached Tokens", + "armRegionName": "centralindia", + "retailPrice": 4E-05, + "unitOfMeasure": "1K", + "meterId": "2451e87a-d1ad-532e-9844-234d994b3646", + "effectiveStartDate": "2025-07-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Lite - Custom Speech Audio Input Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.038, + "unitOfMeasure": "1K", + "meterId": "600c3d3e-9f94-5bea-8d31-911c8250672f", + "effectiveStartDate": "2025-12-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Lite - Custom Speech Audio Output Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.05, + "unitOfMeasure": "1K", + "meterId": "53980d9d-4dbc-5a7d-9bff-815f3f5b5d24", + "effectiveStartDate": "2025-07-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Lite - LLM Audio Cached Tokens", + "armRegionName": "centralindia", + "retailPrice": 4E-05, + "unitOfMeasure": "1K", + "meterId": "33353760-39ea-551d-ba87-cceb2e65cab1", + "effectiveStartDate": "2025-07-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Lite - LLM Audio Input Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.004, + "unitOfMeasure": "1K", + "meterId": "0b8a0748-2c89-56f6-918f-b3e8665373ee", + "effectiveStartDate": "2025-07-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Lite - LLM Audio Output Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.012, + "unitOfMeasure": "1K", + "meterId": "d06a63bf-d7c1-5f6e-a29c-29c6edbdc8ea", + "effectiveStartDate": "2026-10-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Lite - LLM Text Cached Tokens", + "armRegionName": "centralindia", + "retailPrice": 4E-05, + "unitOfMeasure": "1K", + "meterId": "5c352ddf-8325-58fd-b4e1-c6c2a56f2b34", + "effectiveStartDate": "2025-07-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Lite - LLM Text Input Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.00011, + "unitOfMeasure": "1K", + "meterId": "778146b3-89ca-5476-82a6-8f9f91100d72", + "effectiveStartDate": "2025-12-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Lite - LLM Text Output Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.00044, + "unitOfMeasure": "1K", + "meterId": "087dc8f3-fe02-599c-b9b3-95562c6ba6ec", + "effectiveStartDate": "2025-12-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Lite - Standard Speech Audio Cached Tokens", + "armRegionName": "centralindia", + "retailPrice": 4E-05, + "unitOfMeasure": "1K", + "meterId": "231fedef-d50b-51ed-81d5-90d7a6373140", + "effectiveStartDate": "2025-07-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Lite - Standard Speech Audio Input Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.015, + "unitOfMeasure": "1K", + "meterId": "82deea18-da04-5552-a4d3-599b64c93ca4", + "effectiveStartDate": "2025-12-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Lite - Standard Speech Audio Output Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.025, + "unitOfMeasure": "1K", + "meterId": "5a77baa4-7b58-59a6-9e0e-53d55bb0c497", + "effectiveStartDate": "2026-05-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Pro - Custom Speech Audio Cached Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.0004, + "unitOfMeasure": "1K", + "meterId": "a9d8d0ca-650e-5ac8-a9a3-0e3ddbc9b946", + "effectiveStartDate": "2026-05-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Pro - Custom Speech Audio Input Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.04, + "unitOfMeasure": "1K", + "meterId": "4dea12b3-dce0-52b4-9db8-d52faab2f761", + "effectiveStartDate": "2025-11-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Pro - Custom Speech Audio Output Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.055, + "unitOfMeasure": "1K", + "meterId": "3c3232bd-e4a2-538e-8a1f-2b9f7e324198", + "effectiveStartDate": "2025-10-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Pro - LLM Audio Cached Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.0004, + "unitOfMeasure": "1K", + "meterId": "da525934-5ed9-54f6-a3aa-415d2463577a", + "effectiveStartDate": "2026-05-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Pro - LLM Audio Input Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.032, + "unitOfMeasure": "1K", + "meterId": "44fb4f1e-c512-58f6-9e99-d93953f28597", + "effectiveStartDate": "2026-05-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Pro - LLM Audio Output Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.064, + "unitOfMeasure": "1K", + "meterId": "964cd0d3-98af-5da1-bb60-cb0ed0ce4e1f", + "effectiveStartDate": "2026-05-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Pro - LLM Image Cached Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.0005, + "unitOfMeasure": "1K", + "meterId": "9249e3d6-e84c-5d7b-90d8-3aa33b1aae9e", + "effectiveStartDate": "2026-06-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Pro - LLM Image Input Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.005, + "unitOfMeasure": "1K", + "meterId": "8911297b-83e8-571a-91f6-e7172ca6baf1", + "effectiveStartDate": "2026-06-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Pro - LLM Text Cached Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.0004, + "unitOfMeasure": "1K", + "meterId": "35628f43-f681-5e75-a0f2-8e97cd1d230b", + "effectiveStartDate": "2026-05-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Pro - LLM Text Input Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.004, + "unitOfMeasure": "1K", + "meterId": "104c7aa5-3357-567f-96a2-7e6fe85b7054", + "effectiveStartDate": "2026-05-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Pro - LLM Text Output 2 Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.024, + "unitOfMeasure": "1K", + "meterId": "0f93b46a-cf94-5d8b-b273-101c4966ce65", + "effectiveStartDate": "2026-09-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Pro - LLM Text Output Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.016, + "unitOfMeasure": "1K", + "meterId": "9e8d8d17-e362-5e06-96e9-bb07e6b2c666", + "effectiveStartDate": "2026-05-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Pro - Standard Speech Audio Cached Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.0004, + "unitOfMeasure": "1K", + "meterId": "6e4ca7bf-98ef-5d0e-881e-2fdb65cdcd20", + "effectiveStartDate": "2026-05-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Pro - Standard Speech Audio Input Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.017, + "unitOfMeasure": "1K", + "meterId": "2c73f57e-cff4-5424-a904-9cccd7c0635c", + "effectiveStartDate": "2025-10-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Pro - Standard Speech Audio Output Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.031, + "unitOfMeasure": "1K", + "meterId": "5eba2cf3-1011-5bf9-acf8-1e37c572081f", + "effectiveStartDate": "2026-05-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Std - Custom Speech Audio Cached Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.00033, + "unitOfMeasure": "1K", + "meterId": "847f69da-26ea-57f3-8215-56e0c7f1a480", + "effectiveStartDate": "2025-10-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Std - Custom Speech Audio Input Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.039, + "unitOfMeasure": "1K", + "meterId": "eeefb8f0-9e1f-5057-832c-ead15101e3c3", + "effectiveStartDate": "2025-11-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Std - Custom Speech Audio Output Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.05, + "unitOfMeasure": "1K", + "meterId": "e0fbeb26-8b2e-5f6f-9f8f-3e4c8fa823ee", + "effectiveStartDate": "2025-10-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Std - LLM Audio Input Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.011, + "unitOfMeasure": "1K", + "meterId": "a2852431-b6fb-586d-8d35-63df12c40da7", + "effectiveStartDate": "2025-10-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Std - LLM Text Cached Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.00033, + "unitOfMeasure": "1K", + "meterId": "4ec51571-29b6-5629-9f1d-397004e75013", + "effectiveStartDate": "2025-10-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Std - LLM Text Input Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.00066, + "unitOfMeasure": "1K", + "meterId": "4ba0af47-b7a5-5998-bb00-ef8b20aa876e", + "effectiveStartDate": "2025-10-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Std - LLM Text Output Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.00264, + "unitOfMeasure": "1K", + "meterId": "0237e508-cd90-5c98-a84e-d843749c8701", + "effectiveStartDate": "2025-10-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Std - Standard Speech Audio Cache Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.00033, + "unitOfMeasure": "1K", + "meterId": "cb79685c-d286-5674-9c74-965dc723612f", + "effectiveStartDate": "2025-10-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Std - Standard Speech Audio Input Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.015, + "unitOfMeasure": "1K", + "meterId": "9f817cdf-b12b-594f-985f-8e57973b4bbb", + "effectiveStartDate": "2025-10-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Std - Standard Speech Audio Outpt Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.026, + "unitOfMeasure": "1K", + "meterId": "dcffcdbe-b70e-5dfc-a302-49bde0c658e3", + "effectiveStartDate": "2026-05-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Std- LLM Audio Cached Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.00033, + "unitOfMeasure": "1K", + "meterId": "2db7c3e3-195f-5b34-b425-231c4060575f", + "effectiveStartDate": "2025-10-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live API Std- LLM Audio Output Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.022, + "unitOfMeasure": "1K", + "meterId": "ee45d7c1-495f-5c58-91d5-cf471353b688", + "effectiveStartDate": "2025-10-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live BYO Custom Speech Audio Input Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.036, + "unitOfMeasure": "1K", + "meterId": "d6c1a62b-a37a-5fa9-91d2-42dd7678d72c", + "effectiveStartDate": "2025-11-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live BYO Custom Speech Audio Output Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.047, + "unitOfMeasure": "1K", + "meterId": "8ddc355d-cd31-53b3-b289-2b812848ebed", + "effectiveStartDate": "2025-11-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live BYO Standard Speech Audio Input Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.0125, + "unitOfMeasure": "1K", + "meterId": "e8a98aa0-b1ce-5a09-88b7-16f47d3072cb", + "effectiveStartDate": "2025-11-01T00:00:00Z" + }, + { + "productName": "Azure Speech", + "skuName": null, + "meterName": "Voice Live BYO Standard Speech Audio Output Tokens", + "armRegionName": "centralindia", + "retailPrice": 0.023, + "unitOfMeasure": "1K", + "meterId": "78a6f6ea-3911-593c-81b4-cd164fbf9782", + "effectiveStartDate": "2026-05-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI GPT5", + "skuName": "5.4 cd inp Gl", + "meterName": "5.4 cd inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.25, + "unitOfMeasure": "1M", + "meterId": "46546556-0960-5dd0-88de-f43db9bcf072", + "effectiveStartDate": "2026-03-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI GPT5", + "skuName": "5.4 inp Gl", + "meterName": "5.4 inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 2.5, + "unitOfMeasure": "1M", + "meterId": "3c64d694-28a6-59d5-a0c3-d47f3fa52747", + "effectiveStartDate": "2026-03-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI GPT5", + "skuName": "5.4 mini cd Inp Gl", + "meterName": "5.4 mini cd Inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.075, + "unitOfMeasure": "1M", + "meterId": "b5b41edc-425c-5990-92a7-9728060089e4", + "effectiveStartDate": "2026-03-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI GPT5", + "skuName": "5.4 mini Inp Gl", + "meterName": "5.4 mini Inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.75, + "unitOfMeasure": "1M", + "meterId": "f349f2be-000a-5cea-a034-614451b40051", + "effectiveStartDate": "2026-03-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI GPT5", + "skuName": "5.4 mini Opt Gl", + "meterName": "5.4 mini Opt Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 4.5, + "unitOfMeasure": "1M", + "meterId": "fe834fe4-bfae-52b7-8335-cd2b606abe6a", + "effectiveStartDate": "2026-03-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI GPT5", + "skuName": "5.4 nano cd Inp Gl", + "meterName": "5.4 nano cd Inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.02, + "unitOfMeasure": "1M", + "meterId": "9078014a-5ae1-5219-b42a-ac8126993205", + "effectiveStartDate": "2026-03-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI GPT5", + "skuName": "5.4 nano Inp Gl", + "meterName": "5.4 nano Inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.2, + "unitOfMeasure": "1M", + "meterId": "42756a72-c3ef-5218-9897-918e33ca4a02", + "effectiveStartDate": "2026-03-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI GPT5", + "skuName": "5.4 nano Opt Gl", + "meterName": "5.4 nano Opt Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 1.25, + "unitOfMeasure": "1M", + "meterId": "b427f705-091f-53c7-b550-54f1ec16e998", + "effectiveStartDate": "2026-03-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI GPT5", + "skuName": "5.4 opt Gl", + "meterName": "5.4 opt Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 15.0, + "unitOfMeasure": "1M", + "meterId": "804eb780-b8b8-5d0d-82da-9a6e283c898d", + "effectiveStartDate": "2026-03-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI GPT6", + "skuName": "6-sol ShortCo Cd Inp Std Gl", + "meterName": "6-sol ShortCo Cd Inp Std Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.2, + "unitOfMeasure": "1M", + "meterId": "abacf1bc-1e6b-56dc-8703-44a4b360d94e", + "effectiveStartDate": "2026-09-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI GPT6", + "skuName": "6-sol ShortCo Inp Std Gl", + "meterName": "6-sol ShortCo Inp Std Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 2.0, + "unitOfMeasure": "1M", + "meterId": "9c7133ae-806f-5b1f-8771-7c0511f8109a", + "effectiveStartDate": "2026-09-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI GPT6", + "skuName": "6-sol ShortCo Opt Std Gl", + "meterName": "6-sol ShortCo Opt Std Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 10.0, + "unitOfMeasure": "1M", + "meterId": "6901dd08-a8da-5f1d-b1ff-3bc0a8f5901c", + "effectiveStartDate": "2026-09-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI", + "skuName": "gpt 4.1 cached Inp glbl", + "meterName": "gpt 4.1 cached Inp glbl Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.0005, + "unitOfMeasure": "1K", + "meterId": "063ad69e-7a5d-53d7-9201-b69801faecef", + "effectiveStartDate": "2025-04-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI", + "skuName": "gpt 4.1 Inp glbl", + "meterName": "gpt 4.1 Inp glbl Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.002, + "unitOfMeasure": "1K", + "meterId": "9d753020-15f9-5e7f-b822-9842c33eb8c8", + "effectiveStartDate": "2025-04-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI", + "skuName": "gpt 4.1 mini cached Inp glbl", + "meterName": "gpt 4.1 mini cached Inp glbl Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.0001, + "unitOfMeasure": "1K", + "meterId": "9b63aee9-f27b-54a0-bdbf-1113d473d9c1", + "effectiveStartDate": "2025-04-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI", + "skuName": "gpt 4.1 mini Inp glbl", + "meterName": "gpt 4.1 mini Inp glbl Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.0004, + "unitOfMeasure": "1K", + "meterId": "bcb4335d-db5e-5876-a018-21dc32f9af0c", + "effectiveStartDate": "2025-04-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI", + "skuName": "gpt 4.1 mini Outp glbl", + "meterName": "gpt 4.1 mini Outp glbl Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.0016, + "unitOfMeasure": "1K", + "meterId": "6424d431-1d2e-5a45-a701-544bb6a3feb4", + "effectiveStartDate": "2025-04-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI", + "skuName": "gpt 4.1 nano cached Inp glbl", + "meterName": "gpt 4.1 nano cached Inp glbl Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 2.5E-05, + "unitOfMeasure": "1K", + "meterId": "17b59b1e-7051-52eb-95f2-6db44c542e70", + "effectiveStartDate": "2025-04-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI", + "skuName": "gpt 4.1 nano Inp glbl", + "meterName": "gpt 4.1 nano Inp glbl Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.0001, + "unitOfMeasure": "1K", + "meterId": "728aef44-ab5b-547c-89f1-928270c4bda9", + "effectiveStartDate": "2025-04-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI", + "skuName": "gpt 4.1 nano Outp glbl", + "meterName": "gpt 4.1 nano Outp glbl Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.0004, + "unitOfMeasure": "1K", + "meterId": "fb8b4410-2edb-5350-805d-1f1346bc375b", + "effectiveStartDate": "2025-04-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI", + "skuName": "gpt 4.1 Outp glbl", + "meterName": "gpt 4.1 Outp glbl Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.008, + "unitOfMeasure": "1K", + "meterId": "9ea29d81-72dd-5504-b855-554d1c859221", + "effectiveStartDate": "2025-04-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI GPT5", + "skuName": "GPT 5.1 cd inp Gl", + "meterName": "GPT 5.1 cd inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.125, + "unitOfMeasure": "1M", + "meterId": "fb8e4442-5993-5887-a571-5f19a0a41dc5", + "effectiveStartDate": "2025-11-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI GPT5", + "skuName": "GPT 5.1 inp Gl", + "meterName": "GPT 5.1 inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 1.25, + "unitOfMeasure": "1M", + "meterId": "c74cc86e-ddbe-55ed-b8ba-0aa43295e08c", + "effectiveStartDate": "2025-11-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI GPT5", + "skuName": "GPT 5.1 opt Gl", + "meterName": "GPT 5.1 opt Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 10.0, + "unitOfMeasure": "1M", + "meterId": "52d8b2da-ff64-5f3a-b276-406f66aeaa1e", + "effectiveStartDate": "2025-11-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI GPT5", + "skuName": "GPT 5.2 cd inp Gl", + "meterName": "GPT 5.2 cd inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.175, + "unitOfMeasure": "1M", + "meterId": "7068fe60-abf9-5a6d-86bd-3019ee6f541b", + "effectiveStartDate": "2025-12-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI GPT5", + "skuName": "GPT 5.2 inp Gl", + "meterName": "GPT 5.2 inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 1.75, + "unitOfMeasure": "1M", + "meterId": "93adc554-7546-57c9-8fd1-2057b05f8a97", + "effectiveStartDate": "2025-12-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI GPT5", + "skuName": "GPT 5.2 opt Gl", + "meterName": "GPT 5.2 opt Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 14.0, + "unitOfMeasure": "1M", + "meterId": "078140c7-2249-596c-8c9f-644a5bf6ffc6", + "effectiveStartDate": "2025-12-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt rt 1.5 aud cd inp Gl", + "meterName": "gpt rt 1.5 aud cd inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.4, + "unitOfMeasure": "1M", + "meterId": "f17f2cc3-227a-5cde-8324-b6ab13241b0c", + "effectiveStartDate": "2026-03-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt rt 1.5 aud inp Gl", + "meterName": "gpt rt 1.5 aud inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 32.0, + "unitOfMeasure": "1M", + "meterId": "1df13ee2-447d-5c7a-bbfa-365b605dbb87", + "effectiveStartDate": "2026-03-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt rt 1.5 aud opt Gl", + "meterName": "gpt rt 1.5 aud opt Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 64.0, + "unitOfMeasure": "1M", + "meterId": "38c61175-efb3-5405-8e2a-94caa8f6de00", + "effectiveStartDate": "2026-03-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt rt 1.5 txt cd inp Gl", + "meterName": "gpt rt 1.5 txt cd inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.4, + "unitOfMeasure": "1M", + "meterId": "fc27ad5c-7263-587d-9fa2-7334a38f361f", + "effectiveStartDate": "2026-03-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt rt 1.5 txt inp Gl", + "meterName": "gpt rt 1.5 txt inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 4.0, + "unitOfMeasure": "1M", + "meterId": "0c4e0a73-cfa5-56aa-84ca-5e4ab1d0f8de", + "effectiveStartDate": "2026-03-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt rt 1.5 txt opt Gl", + "meterName": "gpt rt 1.5 txt opt Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 16.0, + "unitOfMeasure": "1M", + "meterId": "14c6150c-eb64-5bee-be38-7fd4428cec7a", + "effectiveStartDate": "2026-03-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt rt aud 0828 cchd Inp glbl", + "meterName": "gpt rt aud 0828 cchd Inp glbl Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.0004, + "unitOfMeasure": "1K", + "meterId": "de0d6ed6-a4b3-5dc8-9ea5-8821512cca7a", + "effectiveStartDate": "2025-09-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt rt aud 0828 Inp glbl", + "meterName": "gpt rt aud 0828 Inp glbl Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.032, + "unitOfMeasure": "1K", + "meterId": "48dcf1ab-1fb6-5a08-afea-23392e41ab83", + "effectiveStartDate": "2025-09-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt rt aud 0828 Outp glbl", + "meterName": "gpt rt aud 0828 Outp glbl Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.064, + "unitOfMeasure": "1K", + "meterId": "08c4cfda-2d19-5c4a-a66a-8902e1230c61", + "effectiveStartDate": "2025-09-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt rt txt 0828 cchd Inp glbl", + "meterName": "gpt rt txt 0828 cchd Inp glbl Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.0004, + "unitOfMeasure": "1K", + "meterId": "bf0c8482-02be-583d-94a6-5a3afd2d14de", + "effectiveStartDate": "2025-09-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt rt txt 0828 Inp glbl", + "meterName": "gpt rt txt 0828 Inp glbl Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.004, + "unitOfMeasure": "1K", + "meterId": "cf79b333-138b-5fca-97cd-d2c4fc9f9649", + "effectiveStartDate": "2025-09-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt rt txt 0828 Outp glbl", + "meterName": "gpt rt txt 0828 Outp glbl Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.016, + "unitOfMeasure": "1K", + "meterId": "bb4158e0-76cf-5e35-9897-1abfb5bb7cc4", + "effectiveStartDate": "2025-09-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI OSS Models", + "skuName": "gpt-oss-120B Inp glbl", + "meterName": "gpt-oss-120B Inp glbl Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.00015, + "unitOfMeasure": "1K", + "meterId": "bb83f69b-dd73-522b-a669-6a98df6e7f17", + "effectiveStartDate": "2026-03-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI OSS Models", + "skuName": "gpt-oss-120B Outp glbl", + "meterName": "gpt-oss-120B Outp glbl Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.0006, + "unitOfMeasure": "1K", + "meterId": "a3debfc8-f101-53a3-aa55-39242973de0a", + "effectiveStartDate": "2026-03-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt-realtime-2.1 Audio cd inp Gl", + "meterName": "gpt-realtime-2.1 Audio cd inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.4, + "unitOfMeasure": "1M", + "meterId": "2e79dae9-868d-5a66-84bf-3f8684648390", + "effectiveStartDate": "2026-07-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt-realtime-2.1 Audio inp Gl", + "meterName": "gpt-realtime-2.1 Audio inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 32.0, + "unitOfMeasure": "1M", + "meterId": "69e97b35-c5eb-5dc5-8610-54ed5bf11281", + "effectiveStartDate": "2026-07-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt-realtime-2.1 Audio opt Gl", + "meterName": "gpt-realtime-2.1 Audio opt Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 64.0, + "unitOfMeasure": "1M", + "meterId": "a0374ba3-ee45-5fe3-8ccb-d69a7909be99", + "effectiveStartDate": "2026-07-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt-realtime-2.1 Text cd inp Gl", + "meterName": "gpt-realtime-2.1 Text cd inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.4, + "unitOfMeasure": "1M", + "meterId": "7185c1d1-a550-548c-8016-8efe24be1ec8", + "effectiveStartDate": "2026-07-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt-realtime-2.1 Text inp Gl", + "meterName": "gpt-realtime-2.1 Text inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 4.0, + "unitOfMeasure": "1M", + "meterId": "77959174-b457-53d9-87f2-7708410e7af9", + "effectiveStartDate": "2026-07-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt-realtime-2.1 Text opt Gl", + "meterName": "gpt-realtime-2.1 Text opt Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 24.0, + "unitOfMeasure": "1M", + "meterId": "48093253-5cb5-59c8-beff-a2f28ede9457", + "effectiveStartDate": "2026-07-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt-realtime-2.1-mini Audio cd inp Gl", + "meterName": "gpt-realtime-2.1-mini Audio cd inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.3, + "unitOfMeasure": "1M", + "meterId": "dce141b7-74c8-544d-b6df-c7e591a8775a", + "effectiveStartDate": "2026-07-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt-realtime-2.1-mini Audio inp Gl", + "meterName": "gpt-realtime-2.1-mini Audio inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 10.0, + "unitOfMeasure": "1M", + "meterId": "b6661954-725d-5c12-adcc-089b9458b6eb", + "effectiveStartDate": "2026-07-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt-realtime-2.1-mini Audio opt Gl", + "meterName": "gpt-realtime-2.1-mini Audio opt Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 20.0, + "unitOfMeasure": "1M", + "meterId": "995afb3d-9766-5b78-a62d-08be74f77d86", + "effectiveStartDate": "2026-07-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt-realtime-2.1-mini Text cd inp Gl", + "meterName": "gpt-realtime-2.1-mini Text cd inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.06, + "unitOfMeasure": "1M", + "meterId": "772b8ed5-67e7-54ea-9680-39d8bc6c2554", + "effectiveStartDate": "2026-07-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt-realtime-2.1-mini Text inp Gl", + "meterName": "gpt-realtime-2.1-mini Text inp Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 0.6, + "unitOfMeasure": "1M", + "meterId": "dd46697e-3464-5ebc-a3e5-bfa65733c8c1", + "effectiveStartDate": "2026-07-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "gpt-realtime-2.1-mini Text opt Gl", + "meterName": "gpt-realtime-2.1-mini Text opt Gl 1M Tokens", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 2.4, + "unitOfMeasure": "1M", + "meterId": "81fed7ed-ebe6-557f-b213-d4f88afe0e58", + "effectiveStartDate": "2026-07-01T00:00:00Z" + }, + { + "productName": "Azure OpenAI Media", + "skuName": "Live 1 Gl", + "meterName": "Live 1 Gl Unit", + "armRegionName": "southindia", + "processingScope": "Global", + "retailPrice": 3.0, + "unitOfMeasure": "1 Hour", + "meterId": "2e5b0a5c-3723-50a8-ba75-a5473f702fc1", + "effectiveStartDate": "2026-09-01T00:00:00Z" + } + ] +} diff --git a/tools/cost_calculator/server.mjs b/tools/cost_calculator/server.mjs new file mode 100644 index 0000000..6183318 --- /dev/null +++ b/tools/cost_calculator/server.mjs @@ -0,0 +1,34 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const allowed = new Set(["index.html", "styles.css", "app.mjs", "calculator.mjs", "prices.json"]); +const mime = { html: "text/html", css: "text/css", mjs: "text/javascript", json: "application/json" }; +const port = Number(process.env.PORT || 8095); +if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT must be an integer from 1 to 65535."); +const server = createServer(async (req, res) => { + const path = new URL(req.url, "http://localhost").pathname; + const file = path === "/" ? "index.html" : path.slice(1); + if (!["GET", "HEAD"].includes(req.method) || !allowed.has(file)) { + res.writeHead(404).end("Not found"); + return; + } + try { + const content = await readFile(fileURLToPath(new URL(file, import.meta.url))); + res.writeHead(200, { + "Content-Type": `${mime[file.split(".").at(-1)]}; charset=utf-8`, + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'", + }); + res.end(req.method === "HEAD" ? undefined : content); + } catch (error) { + console.error(`Failed to serve ${file}:`, error.message); + res.writeHead(500).end("Unable to read calculator asset. See server log."); + } +}); +server.on("error", error => { + console.error(`Calculator server failed: ${error.message}`); + process.exitCode = 1; +}); +server.listen(port, "127.0.0.1", () => console.log(`Voice Agent Cost Lab: http://127.0.0.1:${port}`)); diff --git a/tools/cost_calculator/styles.css b/tools/cost_calculator/styles.css new file mode 100644 index 0000000..5a4995f --- /dev/null +++ b/tools/cost_calculator/styles.css @@ -0,0 +1,3 @@ +:root{font-family:Inter,"Segoe UI",system-ui,sans-serif;color:#243331;background:#f3f5f1;font-synthesis:none;--green:#176b4b;--line:#dce2db;--muted:#62716c} +*{box-sizing:border-box}body{margin:0}header,main,footer{max-width:1510px;margin:auto}header{padding:42px 38px 30px;display:flex;justify-content:space-between;align-items:center;gap:24px}.eyebrow{font-size:11px;letter-spacing:2px;font-weight:700;color:var(--green)}h1{font-size:54px;letter-spacing:-3px;margin:4px 0}h1 span{color:#419c66}p{line-height:1.6}header p{color:var(--muted);margin:3px 0}.region{background:#e3ebe1;border:1px solid #d1ddce;padding:15px 20px;border-radius:8px;font-size:11px;letter-spacing:1px;font-weight:700}.region span,.region small{display:block;letter-spacing:0;font-weight:400;color:var(--muted);margin-top:6px}main{display:grid;grid-template-columns:340px minmax(0,1fr);gap:24px;padding:0 38px 35px}section{background:#fff;border:1px solid var(--line);border-radius:12px;padding:24px;margin-bottom:18px}aside section{padding:21px}h2{font-size:19px;margin:6px 0 20px;font-weight:650;letter-spacing:-.4px}aside h2{font-size:16px}h2>span{font-size:11px;color:var(--green);margin-right:9px}label{display:block;font-size:12px;font-weight:600;color:#52615b;margin:0 0 15px}input,select{display:block;width:100%;padding:10px 11px;margin-top:7px;border:1px solid #d5ddd6;border-radius:6px;background:#fcfdfb;color:#233b30;font:inherit;font-size:13px;min-width:0}input:focus,select:focus{outline:2px solid #8fc1a3;outline-offset:1px}input:invalid{border-color:#b65232}.fields{display:grid;grid-template-columns:1fr 1fr;gap:0 13px}.hint{font-size:12px;color:var(--muted);line-height:1.65;margin:10px 0 0}.section-top{display:flex;align-items:center;justify-content:space-between;gap:18px}.cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:13px;margin-top:8px}.cards article{background:#f4f6f2;border:1px solid #e2e8df;border-radius:9px;padding:20px 16px}.cards .accent{background:#e9f3e9;border-color:#c5dec8}.cards article>span{font-size:12px;color:var(--muted)}.cards strong{display:block;font-size:30px;letter-spacing:-1px;margin:12px 0 8px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.cards small{font-size:11px;color:var(--muted)}.badge{border-radius:20px;padding:8px 11px;background:#f0ece1;color:#775e25;font-size:11px;font-weight:600;white-space:nowrap}.badge.good{background:#e1f1e3;color:#21643a}.inline{margin:0;min-width:155px}.inline select{padding:7px}.chart{display:grid;gap:10px;margin:8px 0 20px}.bar-row{display:grid;grid-template-columns:78px minmax(0,1fr) 102px;gap:14px;align-items:center;font-size:12px;font-variant-numeric:tabular-nums}.bar-track{background:#f0f3ef;border-radius:4px;height:25px;overflow:hidden}.bar{height:100%;background:#b4c5bc;border-radius:4px;min-width:2px}.bar.good{background:#549e70}.bar-row strong{text-align:right;font-weight:600}.table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:12px;white-space:nowrap}th{text-align:right;padding:12px 9px;background:#f7f9f5;font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);border-bottom:1px solid var(--line)}th:first-child,td:first-child{text-align:left}td{padding:13px 9px;text-align:right;border-bottom:1px solid #edf0eb;font-variant-numeric:tabular-nums}td.good{color:var(--green);background:#f0f8ef}td.missing{color:#8b6c39}td:first-child small{display:block;color:var(--muted);font-size:10px;margin-top:4px}.secondary{border:1px solid #cdd8ce;background:#fff;color:#355846;border-radius:6px;padding:9px 13px;font:inherit;font-size:12px;cursor:pointer;white-space:nowrap}.secondary:hover{background:#eef4ed}summary{font-weight:650;font-size:15px;cursor:pointer;padding:3px 0}details details{margin-top:18px}details details summary{font-size:12px}#rates{margin-top:20px;grid-template-columns:repeat(3,minmax(0,1fr))}#rates small{display:block;font-weight:400;font-size:10px;color:var(--muted);margin-top:5px}#evidence{font-size:11px;line-height:1.8;padding-left:18px;overflow-wrap:anywhere}.method{background:#edf1e9}.method h2{font-size:16px;margin-bottom:12px}.method p{font-size:12px;color:var(--muted)}a{color:var(--green)}#error{padding:18px;border:1px solid #c87e64;border-radius:8px;background:#fff0ea;color:#8a321d;margin-bottom:18px}footer{padding:0 38px 25px;font-size:10px;letter-spacing:1px;color:var(--muted);display:flex;justify-content:space-between;gap:20px}footer span{letter-spacing:0} +@media(min-width:1400px){main{grid-template-columns:365px minmax(0,1fr)}}@media(max-width:1050px){header,main{padding-left:20px;padding-right:20px}main{grid-template-columns:290px minmax(0,1fr);gap:16px}section{padding:18px}.cards{grid-template-columns:1fr}.cards article{padding:14px;display:grid;grid-template-columns:1fr 1fr;align-items:center}.cards strong{font-size:25px;margin:0;text-align:right}.cards small{grid-column:2;text-align:right;margin-top:5px}.section-top{align-items:flex-start;flex-wrap:wrap}#rates{grid-template-columns:1fr 1fr}}@media(max-width:720px){header{padding-top:25px;align-items:flex-start;flex-direction:column}h1{font-size:45px}main{display:block}aside .fields{grid-template-columns:1fr 1fr}aside section{margin-bottom:12px}.region{width:100%}.cards{grid-template-columns:1fr}.bar-row{grid-template-columns:65px minmax(0,1fr) 95px;gap:8px}footer{flex-direction:column;padding:0 20px 25px}#rates{grid-template-columns:1fr 1fr}}