Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions tools/cost_calculator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tools/cost_calculator/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?
Expand Down Expand Up @@ -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 : "",
Expand Down
8 changes: 5 additions & 3 deletions tools/cost_calculator/calculator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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;
Expand Down
69 changes: 52 additions & 17 deletions tools/cost_calculator/calculator.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -81,22 +81,23 @@ 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")));
});

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", () => {
Expand All @@ -105,21 +106,21 @@ 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);
});

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"));
});

Expand All @@ -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);
});

Expand Down Expand Up @@ -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/);
Expand All @@ -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", () => {
Expand Down
8 changes: 5 additions & 3 deletions tools/cost_calculator/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ <h2><span>02</span> Conversation assumptions</h2>
<div class="fields">
<label>Call duration (sec)<input name="duration" type="number" min="0.01" step="any" required></label>
<label>User / agent turn pairs<input name="turns" type="number" min="1" step="1" required></label>
<label>Human speech (sec)<input name="humanSeconds" type="number" min="0" step="any" required></label>
<label>Agent speech (sec)<input name="agentSeconds" type="number" min="0" step="any" required></label>
<label>Human speech before silence (sec)<input name="humanSeconds" type="number" min="0" step="any" required></label>
<label>Agent speech before silence (sec)<input name="agentSeconds" type="number" min="0" step="any" required></label>
<label>Both parties silent (%)<input name="silencePercent" type="number" min="0" max="100" step="any" required></label>
<label>Input audio tokens/sec<input name="inputAudioRate" type="number" min="0" step="any" required></label>
<label>Output audio tokens/sec<input name="outputAudioRate" type="number" min="0" step="any" required></label>
<label>User text tokens / call<input name="userText" type="number" min="0" step="any" required></label>
Expand All @@ -40,7 +41,7 @@ <h2><span>02</span> Conversation assumptions</h2>
<label>System prompt tokens<input name="prompt" type="number" min="0" step="any" required></label>
<label>Context cache hit (%)<input name="cachePercent" type="number" min="0" max="100" step="any" required></label>
</div>
<p class="hint">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.</p>
<p class="hint">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.</p>
</section>
<section>
<h2><span>03</span> Call volume</h2>
Expand Down Expand Up @@ -94,6 +95,7 @@ <h2><span>03</span> Call volume</h2>
<h2>Read the estimate, not an invoice</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
Expand Down
Loading