diff --git a/tools/cost_calculator/README.md b/tools/cost_calculator/README.md index 894a273..75ae410 100644 --- a/tools/cost_calculator/README.md +++ b/tools/cost_calculator/README.md @@ -133,7 +133,7 @@ Global, Data Zone, Batch, or fine-tuning meters. ## Assumptions and formulas Defaults use the supplied **90-second call**, **4 user + 4 agent -turns**, **45 seconds of speech each**, **100% reused-context caching**, +turns**, **45 seconds of speech allocation each before silence**, **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**. @@ -142,9 +142,25 @@ These defaults are modeling inputs, not measured usage. Audio defaults follow [Voice Live's Azure OpenAI estimates](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/voice-live#token-usage-and-cost-estimation): **10 input audio tokens/sec** and **20 output audio tokens/sec**, giving -**450 input** and **900 output** audio tokens per default call. Both rates +**450 input** and **900 output** audio tokens before the silence adjustment. Both rates remain editable for other model families or measured usage. +**Both-parties-silent ratio defaults to 20%** and is editable from 0 to 100%. +The suggested 10-30% range is a planning assumption, not a universal average. +Multiply each side's entered speech seconds by `1 - silencePercent / 100` +before calculating audio tokens. The default therefore models **36 seconds +per side**, **360 input tokens**, and **720 output tokens**, with 18 seconds +of both-parties silence in the 90-second call. +This scales fresh and replayed native audio, STT, and TTS usage only, not +unit prices. Text, prompt, reasoning, turn count, GPT Live session minutes, +and the full-call per-minute denominator remain unchanged. It is not a +blanket cost discount or a guarantee that silence is unbilled. Set the ratio +to **0%** when entered speech times already exclude silence to avoid double +counting. With smaller baseline speech allocations, the ratio reduces only +those allocations; it does not subtract additional time from the full call. +The summary shows adjusted audio usage; CSV includes `silence_percent` and +the complete assumptions. + 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 diff --git a/tools/cost_calculator/app.mjs b/tools/cost_calculator/app.mjs index 4b94d5d..fb44251 100644 --- a/tools/cost_calculator/app.mjs +++ b/tools/cost_calculator/app.mjs @@ -125,7 +125,7 @@ function renderOverview(a, model, pipeline, result, book) { 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.`; + `${result.inputAudio.toLocaleString("en-US")} fresh caller audio tokens / ${result.outputAudio.toLocaleString("en-US")} generated speech tokens / ${a.turns} user + ${a.turns} agent turns. Audio usage includes ${a.silencePercent}% silence reduction; text and session minutes are unchanged.`; $("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 ? @@ -189,7 +189,7 @@ function renderComparison(a) { 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, + prompt_tokens: prompt, silence_percent: a.silencePercent, 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 : "", diff --git a/tools/cost_calculator/calculator.mjs b/tools/cost_calculator/calculator.mjs index 6b00483..19cb708 100644 --- a/tools/cost_calculator/calculator.mjs +++ b/tools/cost_calculator/calculator.mjs @@ -2,7 +2,7 @@ export const DEFAULTS = Object.freeze({ duration: 90, turns: 4, humanSeconds: 45, agentSeconds: 45, inputAudioRate: 10, outputAudioRate: 20, userText: 150, agentText: 150, nativeText: 0, reasoning: 0, - cachePercent: 100, prompt: 5000, calls: 10000, + cachePercent: 100, silencePercent: 20, prompt: 5000, calls: 10000, }); export const PROMPTS = [0, 1000, 5000, 10000, 20000]; @@ -39,6 +39,7 @@ export function validateAssumptions(a) { 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."); + if (a.silencePercent > 100) throw new Error("Silence percentage must be between 0 and 100."); } export function meterRate(snapshot, name, unit = "tokens") { @@ -134,8 +135,9 @@ export function calculate(a, pipeline, rates) { 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; + const speakingFraction = 1 - a.silencePercent / 100; + const inputAudio = a.humanSeconds * speakingFraction * a.inputAudioRate; + const outputAudio = a.agentSeconds * speakingFraction * 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; diff --git a/tools/cost_calculator/calculator.test.mjs b/tools/cost_calculator/calculator.test.mjs index 65b7062..519dcc1 100644 --- a/tools/cost_calculator/calculator.test.mjs +++ b/tools/cost_calculator/calculator.test.mjs @@ -51,7 +51,7 @@ test("South India GPT-4.1 and realtime prices do not reuse managed tier rates", 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 + 450 * 12.5 + 900 * 23) / 1e6); + close(result.list, (150 * 2 + 450 * 0.5 + 150 * 8 + 360 * 12.5 + 720 * 23) / 1e6); }); test("unverified managed tiers use explicit Global model references, not invented tier rates", () => { @@ -81,9 +81,10 @@ test("default native call counts fresh audio and cached history without speech d const result = calculate({ ...DEFAULTS, prompt: 0 }, "native", book("gpt-realtime-2.1")); assert.equal(DEFAULTS.inputAudioRate, 10); assert.equal(DEFAULTS.outputAudioRate, 20); - assert.equal(result.inputAudio, 450); - assert.equal(result.outputAudio, 900); - close(result.list, (450 * 32 + 900 * 64 + 2025 * 0.4) / 1e6); + assert.equal(DEFAULTS.silencePercent, 20); + assert.equal(result.inputAudio, 360); + assert.equal(result.outputAudio, 720); + close(result.list, (360 * 32 + 720 * 64 + 1620 * 0.4) / 1e6); assert.ok(result.rows.every(r => !r.label.includes("Azure"))); }); @@ -91,12 +92,12 @@ test("input audio rate overrides do not change output audio usage", () => { const rates = book("gpt-realtime-2.1"); const original = calculate({ ...DEFAULTS, prompt: 0, inputAudioRate: 20 }, "native", rates); const updated = calculate({ ...DEFAULTS, prompt: 0 }, "native", rates); - assert.equal(original.inputAudio, 900); - assert.equal(updated.inputAudio, 450); - assert.equal(original.outputAudio, 900); - assert.equal(updated.outputAudio, 900); - close(original.list, (900 * 32 + 900 * 64 + 2700 * 0.4) / 1e6); - close(original.list - updated.list, (450 * 32 + 675 * 0.4) / 1e6); + assert.equal(original.inputAudio, 720); + assert.equal(updated.inputAudio, 360); + assert.equal(original.outputAudio, 720); + assert.equal(updated.outputAudio, 720); + close(original.list, (720 * 32 + 720 * 64 + 2160 * 0.4) / 1e6); + close(original.list - updated.list, (360 * 32 + 540 * 0.4) / 1e6); }); test("system prompt is full price once, reused across the remaining turns", () => { @@ -105,7 +106,7 @@ test("system prompt is full price once, reused across the remaining turns", () = 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 + 2475 * 32 + 900 * 64) / 1e6); + close(noCache.list, (20000 * 4 + 1980 * 32 + 720 * 64) / 1e6); const halfCache = calculate({ ...DEFAULTS, cachePercent: 50 }, "native", rates); close(halfCache.list, (noCache.list + full.list) / 2); }); @@ -113,13 +114,13 @@ test("system prompt is full price once, reused across the remaining turns", () = 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 + 450 * 32 + 150 * 24 + 900 * 31) / 1e6); + close(result.list, (5000 * 4 + 360 * 32 + 150 * 24 + 720 * 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 = (450 * 32 + 675 * 0.4 + 225 * 0.4 + 150 * 24) / 1e6; - close(result.list, llm + 900 * 31 / 1e6); + const llm = (360 * 32 + 540 * 0.4 + 225 * 0.4 + 150 * 24) / 1e6; + close(result.list, llm + 720 * 31 / 1e6); assert.ok(result.rows.every(r => r.key !== "audioOutput")); }); @@ -130,7 +131,7 @@ test("BYOM uses BYO speech meters, not managed tier prices", () => { 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 + (450 * 12.5 + 900 * 23) / 1e6); + close(result.list, llm + (360 * 12.5 + 720 * 23) / 1e6); close(result.listPerMinute, result.list / 1.5); }); @@ -204,7 +205,8 @@ test("results and components expose only list-price fields", () => { 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 }]) { + { agentSeconds: -1 }, { prompt: NaN }, { cachePercent: 101 }, { calls: 1.5 }, { inputAudioRate: Infinity }, + { silencePercent: -1 }, { silencePercent: 101 }, { silencePercent: NaN }, { silencePercent: Infinity }]) { assert.throws(() => calculate({ ...DEFAULTS, ...changes }, "native", book("gpt-realtime"))); } assert.throws(() => calculate(DEFAULTS, "native", { ...book("gpt-realtime"), audioInput: -1 }), /Invalid rate/); @@ -215,7 +217,40 @@ test("zero-priced input is explicit and unused missing rates do not block a tota 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, (450 * 32 + 2025 * 0.4) / 1e6); + close(result.list, (360 * 32 + 1620 * 0.4) / 1e6); +}); + +test("silence scales only audio rows, with zero preserving baseline and full silence removing audio", () => { + const audioKeys = new Set(["audioInput", "audioCached", "audioOutput", "speechInput", "speechOutput"]); + for (const hosting of ["managed", "byom"]) { + for (const [id, pipeline] of [["gpt-realtime-2.1", "native"], ["gpt-realtime-2.1", "hybrid"], + ["gpt-4.1", "cascade"], ["gpt-6-sol", "live"]]) { + const rates = book(id, hosting, pipeline); + const a = { ...DEFAULTS, nativeText: 100, reasoning: 80 }; + const baseline = calculate({ ...a, silencePercent: 0 }, pipeline, rates); + assert.equal(baseline.inputAudio, 450); + assert.equal(baseline.outputAudio, 900); + for (const silencePercent of [10, 20, 30, 100]) { + const result = calculate({ ...a, silencePercent }, pipeline, rates); + const fraction = 1 - silencePercent / 100; + close(result.inputAudio, 450 * fraction); + close(result.outputAudio, 900 * fraction); + for (const row of baseline.rows) { + const actual = result.rows.find(r => r.label === row.label); + const factor = audioKeys.has(row.key) ? fraction : 1; + close(actual?.quantity ?? 0, row.quantity * factor); + close(actual?.list ?? 0, row.list * factor); + if (actual) assert.equal(actual.rate, row.rate); + } + const audioCost = baseline.rows.filter(r => audioKeys.has(r.key)).reduce((sum, row) => sum + row.list, 0); + close(result.list, baseline.list - audioCost * silencePercent / 100); + close(result.listPerMinute, result.list / 1.5); + if (pipeline === "live") close(result.list, baseline.list); + } + } + } + close(calculate({ ...DEFAULTS, prompt: 0, silencePercent: 0 }, "native", book("gpt-realtime-2.1")).list, + (450 * 32 + 900 * 64 + 2025 * 0.4) / 1e6); }); test("all five prompt sizes grow monotonically for every priced model", () => { diff --git a/tools/cost_calculator/index.html b/tools/cost_calculator/index.html index 63a00d0..8d75bbe 100644 --- a/tools/cost_calculator/index.html +++ b/tools/cost_calculator/index.html @@ -29,8 +29,9 @@
Defaults: 10 input and 20 output audio tokens/sec, giving 450 input + 900 output audio tokens. Text totals are independent estimates, not derived from audio. The 150-token user transcript is illustrative.
+Default 20% silence reduces 45 sec per side to 36 sec: 360 input + 720 output audio tokens at 10 input / 20 output tokens/sec. A 10-30% silence range is a planning assumption, not a universal average. Set silence to 0% if your speech times already exclude silence. Text totals and GPT Live session minutes are not discounted.
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.
+Silence is a usage assumption, not a price discount or billing guarantee. Each side's entered speech seconds is multiplied by (1 - silence % / 100) before computing fresh and replayed audio tokens, STT, and TTS. Text, prompt, reasoning, turn count, GPT Live session minutes, and the full-call per-minute denominator stay unchanged. With the default 90-second speaking allocation, 20% represents 18 seconds with both parties silent; avoid counting silence twice when entering measured speech times.
100% caching is an idealized assumption, not guaranteed prefix-cache eligibility. Audio defaults use the public guide's estimates of 10 input and 20 output audio tokens/sec for Azure OpenAI models. 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.