Mercury Genesys Token Calculator

This commit is contained in:
2026-08-07 14:49:39 -04:00
parent 22a5d907d6
commit 89b835d681
35 changed files with 14105 additions and 2912 deletions

View File

@@ -0,0 +1,142 @@
"""Genesys Cloud AI token cost calculator — the engine.
Math only. The vendor's published rate card is the immutable anchor in
:mod:`genesyscalc.ratecard`; everything the consultant authors or the client
supplies lives in the notebook's tagged cells. See
``docs/Calculator_Pattern_V1-00.md``.
"""
from __future__ import annotations
from .appendix import result_json
from .billing import (
MONTHS_PER_YEAR,
CostLine,
CostTotals,
allowance_for,
billable_tokens_monthly,
cost_lines,
total_cost,
)
from .meters import (
Confidence,
LicenceModel,
Meter,
MeterBasis,
Tier,
TokenPrice,
)
from .model import (
CalculatorInputs,
CalculatorResult,
calculate,
compare,
cost_per_interaction,
metered_units,
)
from .ratecard import (
ALLOWANCE_CARRIES_OVER,
COPILOT_COVERS_SUMMARY_RULE,
FREE_TOKENS_PER_MONTH,
HIGHEST_TIER_RULE,
LIST_PRICE_BY_CURRENCY,
METERS,
METERS_VERBATIM,
RATE_CARD_SOURCE,
RATE_CARD_SOURCE_DATE,
VOICE_BOT_ROUNDUP_SECONDS,
ZERO_RATED_VERBATIM,
meter,
per_user_meter,
rate_card_rows,
)
from .sensitivity import DRIVERS, sweep, tornado
from .staging import backstage, backstage_md, on_stage
from .tts import (
TTS_PRICE_PER_MILLION_CHARS,
TTS_SOURCE,
TTS_SOURCE_DATE,
TTS_STANDARD_END_OF_SALE,
TTS_STANDARD_END_OF_SUPPORT,
TtsLine,
TtsUsage,
tts_cost,
tts_free_share,
)
from .usage import (
DeflectionMix,
FeatureUsage,
VoiceBotUsage,
Volumes,
apply_copilot_covers_summary,
subscription_tokens,
virtual_agent_tokens,
voice_bot_billable_minutes,
voice_bot_roundup_uplift,
voice_bot_tokens,
)
__version__ = "0.1.0"
__all__ = [
"ALLOWANCE_CARRIES_OVER",
"COPILOT_COVERS_SUMMARY_RULE",
"DRIVERS",
"FREE_TOKENS_PER_MONTH",
"HIGHEST_TIER_RULE",
"LIST_PRICE_BY_CURRENCY",
"METERS",
"METERS_VERBATIM",
"MONTHS_PER_YEAR",
"RATE_CARD_SOURCE",
"RATE_CARD_SOURCE_DATE",
"TTS_PRICE_PER_MILLION_CHARS",
"TTS_SOURCE",
"TTS_SOURCE_DATE",
"TTS_STANDARD_END_OF_SALE",
"TTS_STANDARD_END_OF_SUPPORT",
"VOICE_BOT_ROUNDUP_SECONDS",
"ZERO_RATED_VERBATIM",
"CalculatorInputs",
"CalculatorResult",
"Confidence",
"CostLine",
"CostTotals",
"DeflectionMix",
"FeatureUsage",
"LicenceModel",
"Meter",
"MeterBasis",
"Tier",
"TokenPrice",
"TtsLine",
"TtsUsage",
"VoiceBotUsage",
"Volumes",
"__version__",
"allowance_for",
"apply_copilot_covers_summary",
"backstage",
"backstage_md",
"billable_tokens_monthly",
"calculate",
"compare",
"cost_lines",
"cost_per_interaction",
"meter",
"metered_units",
"on_stage",
"per_user_meter",
"rate_card_rows",
"result_json",
"subscription_tokens",
"sweep",
"tornado",
"total_cost",
"tts_cost",
"tts_free_share",
"virtual_agent_tokens",
"voice_bot_billable_minutes",
"voice_bot_roundup_uplift",
"voice_bot_tokens",
]

View File

@@ -0,0 +1,126 @@
"""The machine-readable model state (Mercury Notebook Pattern §5).
Plotly figures export as JavaScript an LLM cannot read, so the exported
``.md`` ends with one JSON block carrying everything the figures showed.
``rate_card.source_date`` is the most important field here: it tells a
consumer of the export *which* published rate card produced these numbers.
A cost figure without its rate-card date is not auditable.
"""
from __future__ import annotations
from typing import Any
from .model import CalculatorInputs, CalculatorResult, cost_per_interaction
from .ratecard import (
RATE_CARD_SOURCE,
RATE_CARD_SOURCE_DATE,
VOICE_BOT_ROUNDUP_SECONDS,
)
from .tts import TTS_SOURCE, TTS_SOURCE_DATE
def result_json(
result: CalculatorResult,
inputs: CalculatorInputs,
meta: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""The export payload: provenance, inputs, lines, totals, warnings."""
totals = result.totals
payload: dict[str, Any] = {
"rate_card": {
"source": RATE_CARD_SOURCE,
"source_date": RATE_CARD_SOURCE_DATE,
"voice_bot_roundup_seconds": VOICE_BOT_ROUNDUP_SECONDS,
},
"tts_source": {"source": TTS_SOURCE, "source_date": TTS_SOURCE_DATE},
"inputs": {
"users": inputs.users,
"licence_model": inputs.licence.value,
"enabled_features": sorted(inputs.enabled),
"apply_allowance": inputs.apply_allowance,
"volumes": {
k: v for k, v in vars(inputs.volumes).items() if not k.startswith("_")
},
"deflection_mix": {
"bot_only_share": inputs.mix.bot_only_share,
"virtual_agent_share": inputs.mix.virtual_agent_share,
"agentic_va_share": inputs.mix.agentic_va_share,
"agent_handled_share": inputs.mix.agent_handled_share,
},
"price": {
"currency": inputs.price.currency,
"list_rate": inputs.price.list_rate,
"contracted_rate": inputs.price.contracted_rate,
"concession_pct": inputs.price.concession_pct,
"effective_rate": inputs.price.effective_rate(),
},
"voice_bot": (
{
"calls_per_month": inputs.voice_bot.calls_per_month,
"avg_bot_seconds_per_call": (
inputs.voice_bot.avg_bot_seconds_per_call
),
}
if inputs.voice_bot is not None
else None
),
"tts": (
{
"calls_monthly": inputs.tts.calls_monthly,
"chars_per_call": inputs.tts.chars_per_call,
"tier": inputs.tts.tier,
}
if inputs.tts is not None
else None
),
},
"lines": [
{
"key": line.key,
"feature": line.feature,
"units_monthly": line.units_monthly,
"unit_label": line.unit_label,
"tokens_monthly": line.tokens_monthly,
"cost_monthly": line.cost_monthly,
"cost_annual": line.cost_annual,
"confidence": line.confidence.value,
}
for line in result.lines
],
"totals": {
"consumption_tokens_monthly": totals.consumption_tokens_monthly,
"subscription_tokens_monthly": totals.subscription_tokens_monthly,
"gross_tokens_monthly": totals.gross_tokens_monthly,
"allowance_tokens_monthly": totals.allowance_tokens_monthly,
"billable_tokens_monthly": totals.billable_tokens_monthly,
"currency": totals.currency,
"effective_rate": totals.effective_rate,
"token_cost_monthly": totals.token_cost_monthly,
"token_cost_annual": totals.token_cost_annual,
"list_cost_annual": totals.list_cost_annual,
"concession_saving_annual": totals.concession_saving_annual,
"tts_cost_annual": result.tts_cost_annual,
"grand_total_annual": result.grand_total_annual,
"cost_per_interaction": cost_per_interaction(result, inputs.volumes),
},
"tts": (
{
"tier": result.tts.tier,
"rate_per_million_chars": result.tts.rate_per_million_chars,
"total_chars_monthly": result.tts.total_chars_monthly,
"free_share": result.tts.free_share,
"billable_chars_monthly": result.tts.billable_chars_monthly,
"billed_millions_monthly": result.tts.billed_millions_monthly,
"cost_monthly": result.tts.cost_monthly,
"cost_annual": result.tts.cost_annual,
}
if result.tts is not None
else None
),
"warnings": list(result.warnings),
}
if meta:
payload["meta"] = meta
return payload

View File

@@ -0,0 +1,151 @@
"""Tokens → dollars.
The order of operations is the whole content of this module, and it is
pinned by tests because getting it wrong moves the bill:
1. Round consumption up to whole tokens, monthly. Per-user subscription
totals are exact and are NOT rounded — only metered consumption is.
2. Deduct the free monthly allowance (250 named / 350 concurrent).
3. Floor at zero. The allowance renews monthly and "do[es] not carry over
to future months", so an unused remainder is discarded, never banked.
Everything commercial — the contracted rate, the concession — arrives via
:class:`genesyscalc.meters.TokenPrice` from the notebook's engagement-data
cell. This module knows arithmetic, not deals.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from .meters import Confidence, LicenceModel, TokenPrice
from .ratecard import FREE_TOKENS_PER_MONTH, meter
MONTHS_PER_YEAR: int = 12
@dataclass(frozen=True)
class CostLine:
"""One feature's monthly and annual cost, ready to render."""
key: str
feature: str
units_monthly: float
unit_label: str
tokens_monthly: float
cost_monthly: float
cost_annual: float
confidence: Confidence
source_url: str | None
note: str = ""
@dataclass(frozen=True)
class CostTotals:
"""The token bill, with the allowance and the MSRP walk both visible."""
consumption_tokens_monthly: float
subscription_tokens_monthly: float
gross_tokens_monthly: float
allowance_tokens_monthly: int
billable_tokens_monthly: int
effective_rate: float
currency: str
token_cost_monthly: float
token_cost_annual: float
list_cost_annual: float
concession_saving_annual: float
def billable_tokens_monthly(
consumption_tokens: float,
subscription_tokens: float,
licence: LicenceModel,
apply_allowance: bool = True,
) -> int:
"""Whole tokens actually billed in a month.
``ceil`` lands on the consumption subtotal (Genesys bills whole tokens);
subscription totals are already whole and exact. The allowance is then
deducted and the result floored at zero — it does not carry over, so an
unused remainder is simply lost.
"""
if consumption_tokens < 0 or subscription_tokens < 0:
raise ValueError("token counts must not be negative")
gross = math.ceil(consumption_tokens) + subscription_tokens
allowance = FREE_TOKENS_PER_MONTH[licence] if apply_allowance else 0
return max(0, int(gross) - allowance)
def allowance_for(licence: LicenceModel, apply_allowance: bool = True) -> int:
"""The free monthly token allowance actually in play."""
return FREE_TOKENS_PER_MONTH[licence] if apply_allowance else 0
def cost_lines(
tokens_by_key: dict[str, float], units_by_key: dict[str, float], price: TokenPrice
) -> tuple[CostLine, ...]:
"""One :class:`CostLine` per meter, in published order, at the effective rate.
Line costs are unrounded and un-allowanced: the allowance is an
org-level deduction, not a per-feature one, so attributing it to a
single line would misstate that feature's economics. It lands in
:func:`total_cost`.
"""
rate = price.effective_rate()
lines: list[CostLine] = []
for key in sorted(tokens_by_key, key=lambda k: meter(k).feature):
m = meter(key)
tokens = tokens_by_key[key]
monthly = tokens * rate
lines.append(
CostLine(
key=key,
feature=m.feature,
units_monthly=units_by_key.get(key, 0.0),
unit_label=m.unit_label,
tokens_monthly=tokens,
cost_monthly=monthly,
cost_annual=monthly * MONTHS_PER_YEAR,
confidence=m.confidence,
source_url=m.source_url,
note=m.note,
)
)
return tuple(lines)
def total_cost(
consumption_tokens_monthly: float,
subscription_tokens_monthly: float,
licence: LicenceModel,
price: TokenPrice,
apply_allowance: bool = True,
) -> CostTotals:
"""The org-level bill: allowance deducted, MSRP walk carried alongside."""
billable = billable_tokens_monthly(
consumption_tokens_monthly,
subscription_tokens_monthly,
licence,
apply_allowance,
)
rate = price.effective_rate()
monthly = billable * rate
annual = monthly * MONTHS_PER_YEAR
list_annual = billable * price.list_rate * MONTHS_PER_YEAR
return CostTotals(
consumption_tokens_monthly=consumption_tokens_monthly,
subscription_tokens_monthly=subscription_tokens_monthly,
gross_tokens_monthly=(
math.ceil(consumption_tokens_monthly) + subscription_tokens_monthly
),
allowance_tokens_monthly=allowance_for(licence, apply_allowance),
billable_tokens_monthly=billable,
effective_rate=rate,
currency=price.currency,
token_cost_monthly=monthly,
token_cost_annual=annual,
list_cost_annual=list_annual,
concession_saving_annual=list_annual - annual,
)

View File

@@ -0,0 +1,165 @@
"""Schema for the rate card — types only, no rates.
Rates live in :mod:`genesyscalc.ratecard` as an immutable verbatim anchor.
This module defines what a metered feature *is*, so the anchor can be typed
and the arithmetic can be tested without either knowing the other's
business.
The invariants in ``__post_init__`` are the point: a meter flagged
:attr:`Confidence.CONFIRMED` that carries no source URL and date cannot be
constructed. That is what stops an unsourced number wearing a green tick on
a client-facing stage.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class Confidence(Enum):
"""How much weight a number carries. Rendered on stage as an icon."""
CONFIRMED = "confirmed" # published by the vendor, with a source URL + date
ESTIMATED = "estimated" # working assumption, stated
UNKNOWN = "unknown" # not sourced; bound it with sensitivity if material
@property
def icon(self) -> str:
return {"confirmed": "🟢", "estimated": "🟡", "unknown": "🔴"}[self.value]
class LicenceModel(Enum):
"""Genesys prices per-user AI features differently by licence model."""
NAMED = "named"
CONCURRENT = "concurrent"
@property
def label(self) -> str:
return self.value.capitalize()
class MeterBasis(Enum):
"""How a feature converts activity into tokens."""
PER_USER_PER_MONTH = "per_user_per_month" # Copilot 40/60, STA 30/45
UNITS_PER_TOKEN = "units_per_token" # "51 sessions per token"
TOKENS_PER_UNIT = "tokens_per_unit" # "0.5 tokens per interaction"
VOICE_MINUTES = "voice_minutes" # 17 min/token, 15s per-call round-up
ZERO_RATED = "zero_rated" # "no charge for token usage"
class Tier(Enum):
"""Ordering for the published highest-tier rule.
"Genesys bases charges on the highest tier (price) resource that Genesys
Cloud uses during the interaction." Higher value = higher tier = wins.
An interaction is charged once, at the highest tier it touched — see
:class:`genesyscalc.usage.DeflectionMix`.
"""
BOT = 1
VIRTUAL_AGENT = 2
AGENTIC_VIRTUAL_AGENT = 3
@dataclass(frozen=True)
class Meter:
"""One line of the vendor's published rate card.
``feature`` and ``published_rate`` hold the article's *exact* wording;
``rate`` holds the parsed number the arithmetic uses. Keeping both is
what makes a transcription slip catchable — ``test_rate_card`` pins the
strings, and a separate test proves the float agrees with the string.
"""
key: str
feature: str # the article's exact feature name
published_rate: str # the article's exact rate wording
basis: MeterBasis
rate: float = 0.0 # tokens per unit (0.0 for per-user/zero-rated)
per_user_named: float | None = None
per_user_concurrent: float | None = None
tier: Tier | None = None
confidence: Confidence = Confidence.CONFIRMED
source_url: str | None = None
source_date: str | None = None
unit_label: str = "units"
note: str = ""
def __post_init__(self) -> None:
if self.rate < 0:
raise ValueError(f"{self.key}: rate must not be negative")
if self.confidence is Confidence.CONFIRMED and not (
self.source_url and self.source_date
):
raise ValueError(
f"{self.key}: a confirmed (🟢) meter must carry a source URL and date"
)
per_user = self.basis is MeterBasis.PER_USER_PER_MONTH
has_pair = (
self.per_user_named is not None and self.per_user_concurrent is not None
)
if per_user != has_pair:
raise ValueError(
f"{self.key}: per-user rates are required for "
f"{MeterBasis.PER_USER_PER_MONTH.value} and forbidden otherwise"
)
if self.basis is MeterBasis.ZERO_RATED and self.rate != 0.0:
raise ValueError(f"{self.key}: a zero-rated meter must have rate 0.0")
def tokens_per_user_month(self, licence: LicenceModel) -> float:
"""Subscription tokens for one user for one month."""
if self.basis is not MeterBasis.PER_USER_PER_MONTH:
raise ValueError(f"{self.key} is not a per-user meter")
rate = (
self.per_user_named
if licence is LicenceModel.NAMED
else self.per_user_concurrent
)
assert rate is not None # guaranteed by __post_init__
return rate
def tokens_for(self, units: float) -> float:
"""Consumption tokens for ``units`` of activity.
Voice-bot minutes are NOT priced here — they carry a per-call
round-up that needs call counts, so
:func:`genesyscalc.usage.voice_bot_tokens` owns that path.
"""
if self.basis in (MeterBasis.ZERO_RATED,):
return 0.0
if self.basis is MeterBasis.PER_USER_PER_MONTH:
raise ValueError(f"{self.key}: use tokens_per_user_month()")
return units * self.rate
@dataclass(frozen=True)
class TokenPrice:
"""What one token costs, list versus negotiated.
``contracted_rate`` (a signed per-token rate) wins outright when set.
Otherwise the concession is a percentage off list — the lever a
negotiation actually moves. Both are client data and arrive from the
notebook's ``engagement-data`` cell, never from the anchor.
"""
currency: str = "USD"
list_rate: float = 1.00
contracted_rate: float | None = None
concession_pct: float = 0.0
def __post_init__(self) -> None:
if self.list_rate <= 0:
raise ValueError("list_rate must be positive")
if not 0.0 <= self.concession_pct < 1.0:
raise ValueError("concession_pct must be in [0, 1)")
if self.contracted_rate is not None and self.contracted_rate < 0:
raise ValueError("contracted_rate must not be negative")
def effective_rate(self) -> float:
"""The rate actually billed per token."""
if self.contracted_rate is not None:
return self.contracted_rate
return self.list_rate * (1.0 - self.concession_pct)

View File

@@ -0,0 +1,270 @@
"""Orchestration — one call in, the whole costed picture out.
The notebook assembles :class:`CalculatorInputs` from its ``engagement-data``
cell and its widgets, calls :func:`calculate`, and renders the result. The
engine never invents a volume and never reads a widget.
:attr:`CalculatorResult.warnings` is deliberate: when a published rule makes
a number smaller — Copilot covering summaries, the allowance absorbing a
month, deflection zeroing TTS — the result says so. A total that shrank for
a reason nobody can see is indistinguishable from a bug.
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
from typing import Any
from .billing import CostLine, CostTotals, cost_lines, total_cost
from .meters import LicenceModel, MeterBasis, TokenPrice
from .ratecard import meter
from .tts import TtsLine, TtsUsage, tts_cost
from .usage import (
DeflectionMix,
VoiceBotUsage,
Volumes,
apply_copilot_covers_summary,
subscription_tokens,
virtual_agent_tokens,
voice_bot_billable_minutes,
voice_bot_roundup_uplift,
voice_bot_tokens,
)
#: Meter keys whose units come straight off a channel volume. The notebook's
#: feature catalogue decides which are switched on; this maps each to the
#: volume that drives it.
_VOLUME_DRIVEN: dict[str, str] = {
"bots_digital": "digital_sessions_monthly",
"ai_summary_and_insights": "voice_total_monthly",
"ai_scoring": "evaluations_monthly",
"ai_translate": "translations_monthly",
"predictive_routing": "routed_interactions_monthly",
"genesys_cloud_copilot": "ai_actions_monthly",
"apple_messages_for_business": "messaging_monthly",
"facebook_messenger": "messaging_monthly",
"instagram_direct_messaging": "messaging_monthly",
"whatsapp_messaging": "messaging_monthly",
"x_direct_messaging": "messaging_monthly",
"genesys_cloud_social": "social_posts_monthly",
"social_post_responses": "social_responses_monthly",
"predictive_engagement": "all_interactions_monthly",
"knowledge_queries": "all_interactions_monthly",
}
#: The per-user subscription meters, by licence-model face.
_PER_USER_KEYS: frozenset[str] = frozenset(
{
"agent_copilot_named",
"agent_copilot_concurrent",
"speech_and_text_analytics_named",
"speech_and_text_analytics_concurrent",
}
)
#: Deflection-driven meters — priced from the highest-tier partition, not a
#: raw volume.
_TIER_KEYS: frozenset[str] = frozenset({"virtual_agent", "agentic_virtual_agent"})
#: The Copilot family, in either licence face.
_COPILOT_KEYS: frozenset[str] = frozenset(
{"agent_copilot_named", "agent_copilot_concurrent"}
)
def _volume_for(volumes: Volumes, attr: str) -> float:
return float(getattr(volumes, attr))
@dataclass(frozen=True)
class CalculatorInputs:
"""Everything the notebook supplies. Client data plus widget state."""
volumes: Volumes
users: int
licence: LicenceModel = LicenceModel.NAMED
enabled: frozenset[str] = frozenset()
mix: DeflectionMix = field(default_factory=DeflectionMix)
price: TokenPrice = field(default_factory=TokenPrice)
voice_bot: VoiceBotUsage | None = None
tts: TtsUsage | None = None
apply_allowance: bool = True
def __post_init__(self) -> None:
if self.users < 0:
raise ValueError("users must not be negative")
unknown = sorted(k for k in self.enabled if k not in _known_keys())
if unknown:
raise ValueError(
f"enabled contains keys that are not published meters: {unknown}"
)
@property
def copilot_enabled(self) -> bool:
return bool(self.enabled & _COPILOT_KEYS)
def with_(self, **changes: Any) -> CalculatorInputs:
"""A copy with fields replaced — for scenarios and sensitivity."""
return replace(self, **changes)
def _known_keys() -> frozenset[str]:
from .ratecard import METERS
return frozenset(METERS)
@dataclass(frozen=True)
class CalculatorResult:
"""The costed picture, plus why any number shrank."""
lines: tuple[CostLine, ...]
totals: CostTotals
tts: TtsLine | None
grand_total_annual: float
warnings: tuple[str, ...]
@property
def token_cost_annual(self) -> float:
return self.totals.token_cost_annual
@property
def tts_cost_annual(self) -> float:
return self.tts.cost_annual if self.tts is not None else 0.0
def metered_units(inputs: CalculatorInputs) -> dict[str, float]:
"""Monthly metered units per enabled meter key, before the Copilot rule."""
units: dict[str, float] = {}
for key in sorted(inputs.enabled):
if key in _PER_USER_KEYS:
units[key] = float(inputs.users)
elif key in _TIER_KEYS:
share = (
inputs.mix.virtual_agent_share
if key == "virtual_agent"
else inputs.mix.agentic_va_share
)
units[key] = inputs.volumes.voice_total_monthly * share
elif key == "bots_voice":
units[key] = (
voice_bot_billable_minutes(inputs.voice_bot)
if inputs.voice_bot is not None
else 0.0
)
elif key in _VOLUME_DRIVEN:
units[key] = _volume_for(inputs.volumes, _VOLUME_DRIVEN[key])
else: # pragma: no cover — every published key is classified above
raise ValueError(f"{key} has no unit driver")
return units
def calculate(inputs: CalculatorInputs) -> CalculatorResult:
"""Price a scenario end to end."""
raw_units = metered_units(inputs)
usage = apply_copilot_covers_summary(raw_units, inputs.copilot_enabled)
units = usage.units
warnings: list[str] = list(usage.notes)
tokens: dict[str, float] = {}
consumption = 0.0
# Per-user subscription meters — exact, never rounded.
per_user_enabled = frozenset(inputs.enabled & _PER_USER_KEYS)
subs = subscription_tokens(inputs.users, per_user_enabled, inputs.licence)
tokens.update(subs)
subscription = sum(subs.values())
# Voice bots — minutes with the published per-call round-up.
if "bots_voice" in inputs.enabled and inputs.voice_bot is not None:
bot_tokens = voice_bot_tokens(inputs.voice_bot)
tokens["bots_voice"] = bot_tokens
consumption += bot_tokens
uplift = voice_bot_roundup_uplift(inputs.voice_bot)
if uplift > 0:
warnings.append(
f"Voice bots: the published 15-second per-call round-up adds "
f"{uplift:.1%} to billable bot minutes at a "
f"{inputs.voice_bot.avg_bot_seconds_per_call:.0f}s average."
)
# Virtual agents — the highest-tier partition of voice volume.
tier_tokens = virtual_agent_tokens(
inputs.volumes.voice_total_monthly, inputs.mix
)
for key in sorted(_TIER_KEYS & inputs.enabled):
tokens[key] = tier_tokens.get(key, 0.0)
consumption += tokens[key]
# Everything else — units × published rate.
for key in sorted(inputs.enabled):
if key in _PER_USER_KEYS or key in _TIER_KEYS or key == "bots_voice":
continue
m = meter(key)
value = m.tokens_for(units.get(key, 0.0))
tokens[key] = value
consumption += value
if m.basis is MeterBasis.ZERO_RATED and units.get(key, 0.0) > 0:
warnings.append(
f"{m.feature}: {m.published_rate} — shown at $0 because "
"Genesys publishes it as included."
)
totals = total_cost(
consumption, subscription, inputs.licence, inputs.price, inputs.apply_allowance
)
if inputs.apply_allowance and totals.billable_tokens_monthly == 0:
warnings.append(
f"The free monthly allowance ({totals.allowance_tokens_monthly} "
f"tokens, {inputs.licence.label.lower()} org) covers this month's "
"consumption entirely."
)
tts_line = (
tts_cost(inputs.tts, inputs.mix, inputs.price.concession_pct)
if inputs.tts is not None
else None
)
if tts_line is not None:
warnings.extend(tts_line.warnings)
lines = cost_lines(tokens, units, inputs.price)
grand_total = totals.token_cost_annual + (
tts_line.cost_annual if tts_line is not None else 0.0
)
return CalculatorResult(
lines=lines,
totals=totals,
tts=tts_line,
grand_total_annual=grand_total,
warnings=tuple(warnings),
)
def cost_per_interaction(result: CalculatorResult, volumes: Volumes) -> float:
"""Annual grand total ÷ annual interactions — the number clients recall."""
annual = volumes.all_interactions_monthly * 12
if annual == 0:
return 0.0
return result.grand_total_annual / annual
def compare(scenarios: dict[str, CalculatorInputs]) -> list[dict[str, Any]]:
"""One row per scenario. Cost only — a calculator does not build a case."""
rows: list[dict[str, Any]] = []
for name, inputs in scenarios.items():
result = calculate(inputs)
rows.append(
{
"Scenario": name,
"Billable tokens / mo": result.totals.billable_tokens_monthly,
"Token cost / yr": result.totals.token_cost_annual,
"TTS cost / yr": result.tts_cost_annual,
"Total / yr": result.grand_total_annual,
"$ / interaction": cost_per_interaction(result, inputs.volumes),
}
)
return rows

View File

@@ -0,0 +1,358 @@
"""THE VERBATIM ANCHOR — Genesys Cloud tokens model, as published.
Transcribed from
https://help.genesys.cloud/articles/genesys-cloud-tokens-model/
**last updated 2026-07-12**. Feature names and rate strings below are the
article's own wording, character for character.
VERBATIM — DO NOT EDIT. A vendor republication is a deliberate
**re-anchoring**, not a patch (docs/Calculator_Pattern_V1-00.md):
1. diff the published table against this file
2. update the meters AND bump RATE_CARD_SOURCE_DATE in the same edit
3. update the pins in tests/test_rate_card.py in the same commit
4. re-execute the notebook
5. report the cost moves honestly
6. Show-first — the user sees the rate diff before it lands
Never partially re-anchor; never bump the date without the pins.
Negotiated or contracted values NEVER land here. They are the overlay, and
they live in the notebook's ``engagement-data`` cell — this file is the
vendor's record, not the client's deal.
"""
from __future__ import annotations
from .meters import LicenceModel, Meter, MeterBasis, Tier
RATE_CARD_SOURCE: str = "https://help.genesys.cloud/articles/genesys-cloud-tokens-model/"
RATE_CARD_SOURCE_DATE: str = "2026-07-12"
# Every meter below is published on the same page on the same date, so the
# source pair is spelled out once here and passed explicitly. Explicit beats
# a splat: this file is diffed against the article by eye.
_URL = RATE_CARD_SOURCE
_DATE = RATE_CARD_SOURCE_DATE
#: The published meter table, verbatim. Twenty rows, in the article's order.
METERS_VERBATIM: tuple[Meter, ...] = (
Meter(
key="bots_voice",
feature="Bots (Voice)",
published_rate="17 minutes per token",
basis=MeterBasis.VOICE_MINUTES,
rate=1.0 / 17.0,
tier=Tier.BOT,
unit_label="bot minutes",
note=(
"Genesys rounds up each call to the next 15-second increment "
"(clarified 2026-07-12)."
),
source_url=_URL,
source_date=_DATE,
),
Meter(
key="bots_digital",
feature="Bots (Digital)",
published_rate="51 sessions per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 51.0,
tier=Tier.BOT,
unit_label="sessions",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="virtual_agent",
feature="Virtual Agent",
published_rate="0.5 tokens per Virtual Agent interaction",
basis=MeterBasis.TOKENS_PER_UNIT,
rate=0.5,
tier=Tier.VIRTUAL_AGENT,
unit_label="interactions",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="agentic_virtual_agent",
feature="Agentic Virtual Agent",
published_rate="1.2 tokens per interaction",
basis=MeterBasis.TOKENS_PER_UNIT,
rate=1.2,
tier=Tier.AGENTIC_VIRTUAL_AGENT,
unit_label="interactions",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="agent_copilot_named",
feature="Agent Copilot [named]",
published_rate="40 tokens per user",
basis=MeterBasis.PER_USER_PER_MONTH,
per_user_named=40.0,
per_user_concurrent=60.0,
unit_label="users",
note="Enabling Copilot makes Supervisor summaries and insights free.",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="agent_copilot_concurrent",
feature="Agent Copilot [concurrent]",
published_rate="60 tokens per user",
basis=MeterBasis.PER_USER_PER_MONTH,
per_user_named=40.0,
per_user_concurrent=60.0,
unit_label="users",
note="The concurrent-licence face of the same meter.",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="ai_scoring",
feature="AI Scoring",
published_rate="20 evaluations per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 20.0,
unit_label="evaluations",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="ai_translate",
feature="AI Translate",
published_rate="2 translations per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 2.0,
unit_label="translations",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="ai_summary_and_insights",
feature="AI Summary and Insights",
published_rate="50 summaries/insights per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 50.0,
unit_label="summaries",
note=(
"Free when Agent Copilot is enabled — see "
"COPILOT_COVERS_SUMMARY_RULE."
),
source_url=_URL,
source_date=_DATE,
),
Meter(
key="apple_messages_for_business",
feature="Apple Messages for Business",
published_rate="400 inbound or outbound messages per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 400.0,
unit_label="messages",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="facebook_messenger",
feature="Facebook Messenger",
published_rate="400 messages per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 400.0,
unit_label="messages",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="instagram_direct_messaging",
feature="Instagram Direct Messaging",
published_rate="400 messages per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 400.0,
unit_label="messages",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="whatsapp_messaging",
feature="WhatsApp Messaging",
published_rate="400 messages per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 400.0,
unit_label="messages",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="x_direct_messaging",
feature="X Direct Messaging",
published_rate="400 messages per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 400.0,
unit_label="messages",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="genesys_cloud_social",
feature="Genesys Cloud Social",
published_rate="400 social post ingestions per channel per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 400.0,
unit_label="post ingestions",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="social_post_responses",
feature="Social Post Responses",
published_rate="400 outbound messages per channel per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 400.0,
unit_label="outbound messages",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="predictive_routing",
feature="Predictive Routing",
published_rate="17 routes per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 17.0,
unit_label="routes",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="speech_and_text_analytics_named",
feature="Speech and Text Analytics [named]",
published_rate="30 tokens per user",
basis=MeterBasis.PER_USER_PER_MONTH,
per_user_named=30.0,
per_user_concurrent=45.0,
unit_label="users",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="speech_and_text_analytics_concurrent",
feature="Speech and Text Analytics [concurrent]",
published_rate="45 tokens per user",
basis=MeterBasis.PER_USER_PER_MONTH,
per_user_named=30.0,
per_user_concurrent=45.0,
unit_label="users",
note="The concurrent-licence face of the same meter.",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="genesys_cloud_copilot",
feature="Genesys Cloud Copilot",
published_rate="20 AI actions per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 20.0,
unit_label="AI actions",
note="Genesys Cloud knowledge queries are not charged.",
source_url=_URL,
source_date=_DATE,
),
)
#: Published as included at no token cost. Shown on stage as explicit $0
#: lines — a zero the vendor put in writing is a finding, not an omission.
ZERO_RATED_VERBATIM: tuple[Meter, ...] = (
Meter(
key="predictive_engagement",
feature="Predictive Engagement",
published_rate="No charge for token usage",
basis=MeterBasis.ZERO_RATED,
unit_label="interactions",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="knowledge_queries",
feature="Genesys Cloud knowledge queries",
published_rate="no charge for Genesys Cloud knowledge queries",
basis=MeterBasis.ZERO_RATED,
unit_label="queries",
source_url=_URL,
source_date=_DATE,
),
)
#: "Named organizations receive 250 tokens; concurrent organizations receive
#: 350 tokens" per month.
FREE_TOKENS_PER_MONTH: dict[LicenceModel, int] = {
LicenceModel.NAMED: 250,
LicenceModel.CONCURRENT: 350,
}
#: "These tokens renew each month and do not carry over to future months."
ALLOWANCE_CARRIES_OVER: bool = False
#: "Genesys rounds up each call to the next 15-second increment."
#: Clarified in the 2026-07-12 revision. Applied PER CALL, then summed.
VOICE_BOT_ROUNDUP_SECONDS: int = 15
#: The published highest-tier rule, verbatim. Renders on stage.
HIGHEST_TIER_RULE: str = (
"In cases where an interaction uses multiple AI resources, such as bot "
"flows, virtual agents, and agentic virtual agents, Genesys bases charges "
"on the highest tier (price) resource that Genesys Cloud uses during the "
"interaction."
)
#: The published Copilot exclusion, verbatim. Renders on stage.
COPILOT_COVERS_SUMMARY_RULE: str = (
"if you enable Agent Copilot simultaneously, then Supervisor Copilot "
"summaries and insights do not consume tokens"
)
#: List price per token. The article points to the pricing hub and cites
#: "USD 1.00 to JPY 120 per token by currency".
LIST_PRICE_BY_CURRENCY: dict[str, float] = {"USD": 1.00, "JPY": 120.0}
#: Every meter by key — the consumption table plus the zero-rated lines.
METERS: dict[str, Meter] = {
m.key: m for m in METERS_VERBATIM + ZERO_RATED_VERBATIM
}
def meter(key: str) -> Meter:
"""Look up a published meter, with a useful error when the key is wrong."""
try:
return METERS[key]
except KeyError:
raise KeyError(
f"{key!r} is not a published Genesys meter. Known keys: "
f"{', '.join(sorted(METERS))}"
) from None
def per_user_meter(licence: LicenceModel, family: str) -> Meter:
"""The Copilot / STA meter face matching ``licence``.
``family`` is ``"agent_copilot"`` or ``"speech_and_text_analytics"``.
Both rates live on both faces, so this is presentation sugar — it picks
the row whose published wording matches the licence being modelled.
"""
return meter(f"{family}_{licence.value}")
def rate_card_rows() -> list[dict[str, str]]:
"""The published table as display rows, in publication order."""
return [
{
"Feature": m.feature,
"Published rate": m.published_rate,
"Confidence": m.confidence.icon,
"Source": m.source_date or "",
}
for m in METERS_VERBATIM + ZERO_RATED_VERBATIM
]

View File

@@ -0,0 +1,133 @@
"""One-at-a-time sweeps and tornado data.
Data only — the notebook draws the figures. Drivers are addressed by a
short dotted path so the notebook can name them without reaching into the
dataclasses itself.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import replace
from typing import Any
from .model import CalculatorInputs, CalculatorResult, calculate
#: Driver name → (container attribute on CalculatorInputs, field on it).
#: ``None`` container means the field sits directly on CalculatorInputs.
DRIVERS: dict[str, tuple[str | None, str]] = {
"token_price": ("price", "list_rate"),
"concession_pct": ("price", "concession_pct"),
"users": (None, "users"),
"voice_volume": ("volumes", "voice_inbound_monthly"),
"bot_share": ("mix", "bot_only_share"),
"virtual_agent_share": ("mix", "virtual_agent_share"),
"agentic_va_share": ("mix", "agentic_va_share"),
"bot_seconds": ("voice_bot", "avg_bot_seconds_per_call"),
"tts_chars_per_call": ("tts", "chars_per_call"),
}
def driver_value(inputs: CalculatorInputs, driver: str) -> float:
"""The current value of ``driver`` in ``inputs``."""
container, attr = _resolve(driver)
target = inputs if container is None else getattr(inputs, container)
if target is None:
raise ValueError(f"{driver} is not active in this scenario")
return float(getattr(target, attr))
def set_driver(inputs: CalculatorInputs, driver: str, value: float) -> CalculatorInputs:
"""A copy of ``inputs`` with ``driver`` set to ``value``."""
container, attr = _resolve(driver)
if container is None:
return inputs.with_(**{attr: _coerce(attr, value)})
current = getattr(inputs, container)
if current is None:
raise ValueError(f"{driver} is not active in this scenario")
return inputs.with_(**{container: replace(current, **{attr: _coerce(attr, value)})})
def _resolve(driver: str) -> tuple[str | None, str]:
try:
return DRIVERS[driver]
except KeyError:
raise KeyError(
f"{driver!r} is not a known driver. Known: {', '.join(sorted(DRIVERS))}"
) from None
def _coerce(attr: str, value: float) -> Any:
"""``users`` and ``chars_per_call`` are integers by contract."""
if attr in ("users", "chars_per_call"):
return int(round(value))
return float(value)
def sweep(
base: CalculatorInputs, driver: str, values: Sequence[float]
) -> list[dict[str, float]]:
"""Grand total across a range of one driver, everything else held."""
rows: list[dict[str, float]] = []
for value in values:
result: CalculatorResult = calculate(set_driver(base, driver, value))
rows.append(
{
driver: float(value),
"token_cost_annual": result.totals.token_cost_annual,
"tts_cost_annual": result.tts_cost_annual,
"grand_total_annual": result.grand_total_annual,
}
)
return rows
def tornado(
base: CalculatorInputs, drivers: Sequence[str], delta: float = 0.25
) -> list[dict[str, Any]]:
"""±``delta`` on each driver in turn, sorted by the swing it causes.
Drivers that are not active in the scenario (no TTS, no voice bot) are
skipped rather than raising — a tornado over an inactive lever is noise.
Shares are clamped to keep the highest-tier partition legal; a driver
whose low and high both clamp to the same value contributes no swing.
"""
if not 0.0 < delta < 1.0:
raise ValueError("delta must be in (0, 1)")
baseline = calculate(base).grand_total_annual
rows: list[dict[str, Any]] = []
for driver in drivers:
try:
current = driver_value(base, driver)
except (ValueError, KeyError):
continue
low_value = current * (1.0 - delta)
high_value = current * (1.0 + delta)
if driver.endswith("_share"):
low_value = max(0.0, min(1.0, low_value))
high_value = max(0.0, min(1.0, high_value))
try:
low = calculate(set_driver(base, driver, low_value)).grand_total_annual
high = calculate(set_driver(base, driver, high_value)).grand_total_annual
except ValueError:
# e.g. raising a share would break the partition — skip the lever
continue
rows.append(
{
"driver": driver,
"baseline_value": current,
"low_value": low_value,
"high_value": high_value,
"low": low,
"high": high,
"baseline": baseline,
"swing": abs(high - low),
}
)
return sorted(rows, key=lambda r: float(r["swing"]), reverse=True)

View File

@@ -0,0 +1,61 @@
"""
Stage vs backstage — is this notebook render stakeholder-facing?
The Mercury app (3.2.x) is a hybrid Jupyter server: the SAME server (and
kernel pool) can serve both the client-facing app view and JupyterLab, so a
server-level signal cannot tell who is looking. The reliable, per-view
signal is the session name: the app runs every session against a shadow
copy named ``<notebook>__mercury__<id>.ipynb``
(``mercury_app/handlers.py``), and the kernel sees that path in
``JPY_SESSION_NAME``. JupyterLab and nbconvert sessions carry the plain
notebook path (or no session name at all).
``MERCURY_CONFIG_DIR`` is kept as a fallback: ``mercury --working-dir …``
exports it into the server process and every kernel inherits it. It is a
server-level signal — on such a server even JupyterLab kernels carry it, so
diagnostics are then hidden in that Lab view too (hidden, never leaked; use
a separate ``jupyter lab`` for analysis, which is the normal workflow). It
is NOT set when mercury is launched without ``--working-dir``, which is why
it cannot be the primary signal.
Diagnostics routed through :func:`backstage` / :func:`backstage_md` stay
visible in JupyterLab and land in the nbconvert exports (where the
machine-readable appendix must appear for LLM consumption) but never render
in the Mercury app.
"""
from __future__ import annotations
import os
from typing import Any
def on_stage() -> bool:
"""True when this kernel renders the client-facing Mercury app view."""
if "__mercury__" in os.getenv("JPY_SESSION_NAME", ""):
return True # the app's shadow-copy session
return os.getenv("MERCURY_CONFIG_DIR") is not None
def backstage(*args: object, **kwargs: Any) -> None:
"""``print`` that renders only backstage (JupyterLab, nbconvert)."""
if not on_stage():
print(*args, **kwargs)
def backstage_md(text: str) -> None:
"""Markdown that renders only backstage (JupyterLab, nbconvert).
Emitted as a ``text/markdown`` display, so nbconvert's markdown export
carries it verbatim — a stream ``print`` would be indented as a code
block. Falls back to ``print`` when IPython isn't importable (plain
pytest), where ``display`` itself already degrades to ``print``.
"""
if on_stage():
return
try:
from IPython.display import Markdown, display
except ImportError:
print(text)
return
display(Markdown(text)) # type: ignore[no-untyped-call]

View File

@@ -0,0 +1,147 @@
"""Genesys Enhanced text-to-speech — billed in dollars, NOT in tokens.
A separate published page, a separate meter, a separate subtotal:
https://help.genesys.cloud/articles/genesys-enhanced-tts-pricing/
**last updated 2026-05-22**.
TTS never appears in the tokens-model article and is never converted to
tokens here. It is carried because Genesys AI spend is understated without
it — but it is reported as its own line, and the grand total shows tokens
and TTS as two labelled components rather than one blended figure.
Three published facts the obvious model misses:
* **"The monthly billing unit is one million characters, rounded up."**
200,000 characters bills as $5; 1,000,005 characters bills as $10.
* **Free during virtual agents.** "Genesys Enhanced TTS is included free of
charge when you use a virtual agent or agentic virtual agent during the
interaction" — so deflection cuts this line as well as the token line.
* **Standard voices are ending.** End of sale 2026-03-29, end of support
2026-08-05. Selecting Standard for a future-state model is a finding.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from .meters import Confidence
from .usage import DeflectionMix
TTS_SOURCE: str = "https://help.genesys.cloud/articles/genesys-enhanced-tts-pricing/"
TTS_SOURCE_DATE: str = "2026-05-22"
#: "$20 per one million TTS characters" (advanced: NTTS, neural, wavenet)
#: and "$5 per one million TTS characters" (standard).
TTS_PRICE_PER_MILLION_CHARS: dict[str, float] = {"advanced": 20.0, "standard": 5.0}
#: "The monthly billing unit is one million characters, rounded up."
TTS_BILLING_UNIT_CHARS: int = 1_000_000
#: Standard Enhanced TTS lifecycle — published end-of-sale / end-of-support.
TTS_STANDARD_END_OF_SALE: str = "2026-03-29"
TTS_STANDARD_END_OF_SUPPORT: str = "2026-08-05"
#: Published, so 🟢 — unlike the tokens article, this page states the rates.
TTS_CONFIDENCE: Confidence = Confidence.CONFIRMED
MONTHS_PER_YEAR: int = 12
@dataclass(frozen=True)
class TtsUsage:
"""Synthesised speech volume. Client data, from ``engagement-data``.
"Usage includes all TTS characters, including letters, numbers,
punctuation, and spaces."
"""
calls_monthly: float
chars_per_call: int = 2_000
tier: str = "advanced"
def __post_init__(self) -> None:
if self.calls_monthly < 0:
raise ValueError("calls_monthly must not be negative")
if self.chars_per_call < 0:
raise ValueError("chars_per_call must not be negative")
if self.tier not in TTS_PRICE_PER_MILLION_CHARS:
raise ValueError(
f"unknown TTS tier {self.tier!r}; known: "
f"{', '.join(sorted(TTS_PRICE_PER_MILLION_CHARS))}"
)
@dataclass(frozen=True)
class TtsLine:
"""The TTS bill, reported separately from the token bill."""
tier: str
rate_per_million_chars: float
total_chars_monthly: float
free_share: float
free_chars_monthly: float
billable_chars_monthly: float
billed_millions_monthly: int
cost_monthly: float
cost_annual: float
confidence: Confidence
source_url: str
source_date: str
warnings: tuple[str, ...] = ()
def tts_free_share(mix: DeflectionMix) -> float:
"""Share of TTS characters Genesys does not charge for.
"included free of charge when you use a virtual agent or agentic virtual
agent during the interaction". Reuses the same partition as the token
model, so the two lines cannot disagree about how much was deflected.
"""
return min(1.0, mix.virtual_agent_share + mix.agentic_va_share)
def tts_cost(
usage: TtsUsage, mix: DeflectionMix | None = None, concession_pct: float = 0.0
) -> TtsLine:
"""The monthly and annual TTS bill, with the published per-1M round-up.
The round-up lands on the *billable* character total each month, after
the virtual-agent free share is removed.
"""
if not 0.0 <= concession_pct < 1.0:
raise ValueError("concession_pct must be in [0, 1)")
free_share = tts_free_share(mix) if mix is not None else 0.0
total_chars = usage.calls_monthly * usage.chars_per_call
free_chars = total_chars * free_share
billable_chars = total_chars - free_chars
billed_millions = math.ceil(billable_chars / TTS_BILLING_UNIT_CHARS)
rate = TTS_PRICE_PER_MILLION_CHARS[usage.tier]
monthly = billed_millions * rate * (1.0 - concession_pct)
warnings: list[str] = []
if usage.tier == "standard":
warnings.append(
"Standard Enhanced TTS reached end of sale on "
f"{TTS_STANDARD_END_OF_SALE} and end of support on "
f"{TTS_STANDARD_END_OF_SUPPORT} — price a future state on "
"advanced voices."
)
return TtsLine(
tier=usage.tier,
rate_per_million_chars=rate,
total_chars_monthly=total_chars,
free_share=free_share,
free_chars_monthly=free_chars,
billable_chars_monthly=billable_chars,
billed_millions_monthly=billed_millions,
cost_monthly=monthly,
cost_annual=monthly * MONTHS_PER_YEAR,
confidence=TTS_CONFIDENCE,
source_url=TTS_SOURCE,
source_date=TTS_SOURCE_DATE,
warnings=tuple(warnings),
)

View File

@@ -0,0 +1,262 @@
"""Volumes → metered units → tokens.
Three published rules are modelled here that the obvious arithmetic gets
wrong. Each one is traceable to a sentence in the tokens-model article:
1. **The 15-second per-call round-up.** "Genesys rounds up each call to the
next 15-second increment." Applied per call and *then* summed — averaging
first and rounding once understates the bill, badly, on short bot touches.
2. **The highest-tier rule.** "Genesys bases charges on the highest tier
(price) resource that Genesys Cloud uses during the interaction." So the
AI-resource shares of a volume are a **partition**, not overlapping slices:
an interaction is charged once, at the highest tier it touched.
3. **Copilot covers Supervisor summaries.** "if you enable Agent Copilot
simultaneously, then Supervisor Copilot summaries and insights do not
consume tokens." Billing AI Summary while Copilot is on double-charges.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from .meters import LicenceModel, MeterBasis, Tier
from .ratecard import VOICE_BOT_ROUNDUP_SECONDS, meter
#: Meters covered by the Copilot exclusion when Agent Copilot is enabled.
COPILOT_COVERED_METERS: frozenset[str] = frozenset({"ai_summary_and_insights"})
#: Which per-interaction meter prices each tier of the partition.
TIER_METER: dict[Tier, str] = {
Tier.VIRTUAL_AGENT: "virtual_agent",
Tier.AGENTIC_VIRTUAL_AGENT: "agentic_virtual_agent",
}
@dataclass(frozen=True)
class VoiceBotUsage:
"""Voice-bot activity, in the shape the round-up needs.
The round-up is per *call*, so a call count is required — an aggregate
minute total cannot be billed correctly.
"""
calls_per_month: float
avg_bot_seconds_per_call: float
def __post_init__(self) -> None:
if self.calls_per_month < 0:
raise ValueError("calls_per_month must not be negative")
if self.avg_bot_seconds_per_call < 0:
raise ValueError("avg_bot_seconds_per_call must not be negative")
@dataclass(frozen=True)
class DeflectionMix:
"""Shares of interactions by the **highest tier** each one touched.
This is a partition, not a set of overlapping rates. ``bot_only_share``
is the share whose highest resource was a bot flow; an interaction that
escalated bot → virtual agent counts once, in ``virtual_agent_share``.
The shares therefore sum to at most 1.0 — the remainder went straight to
an agent and consumed no AI-resource token.
Modelling these as independent percentages (and summing the tokens) is
the over-billing bug the published rule exists to prevent.
"""
bot_only_share: float = 0.0
virtual_agent_share: float = 0.0
agentic_va_share: float = 0.0
def __post_init__(self) -> None:
for name, value in (
("bot_only_share", self.bot_only_share),
("virtual_agent_share", self.virtual_agent_share),
("agentic_va_share", self.agentic_va_share),
):
if not 0.0 <= value <= 1.0:
raise ValueError(f"{name} must be in [0, 1], got {value}")
if self.total_deflected_share > 1.0 + 1e-9:
raise ValueError(
"tier shares must sum to at most 1.0 — they partition the "
"volume by highest tier touched, they do not overlap "
f"(got {self.total_deflected_share})"
)
@property
def total_deflected_share(self) -> float:
"""Share of interactions that touched any AI resource."""
return self.bot_only_share + self.virtual_agent_share + self.agentic_va_share
@property
def agent_handled_share(self) -> float:
"""Share that reached a human without touching an AI resource."""
return 1.0 - self.total_deflected_share
@dataclass(frozen=True)
class Volumes:
"""Monthly interaction volumes by channel. Client data — from the
notebook's ``engagement-data`` cell, never invented by the engine."""
voice_inbound_monthly: float = 0.0
voice_outbound_monthly: float = 0.0
digital_sessions_monthly: float = 0.0
email_monthly: float = 0.0
messaging_monthly: float = 0.0
social_posts_monthly: float = 0.0
social_responses_monthly: float = 0.0
translations_monthly: float = 0.0
evaluations_monthly: float = 0.0
ai_actions_monthly: float = 0.0
routed_interactions_monthly: float = 0.0
def __post_init__(self) -> None:
for name, value in vars(self).items():
if value < 0:
raise ValueError(f"{name} must not be negative")
@property
def voice_total_monthly(self) -> float:
return self.voice_inbound_monthly + self.voice_outbound_monthly
@property
def all_interactions_monthly(self) -> float:
return (
self.voice_total_monthly
+ self.digital_sessions_monthly
+ self.email_monthly
+ self.messaging_monthly
)
def voice_bot_billable_minutes(
usage: VoiceBotUsage, roundup_seconds: int = VOICE_BOT_ROUNDUP_SECONDS
) -> float:
"""Billable bot minutes per month, with the published per-call round-up.
"Genesys rounds up each call to the next 15-second increment." The
round-up lands on every call, so it is applied first and summed after::
per_call = ceil(seconds / 15) * 15 / 60
total = per_call * calls
A 40-second average bills as 45 seconds — 0.75 min/call, not 0.667. The
penalty is worst on short touches: a 5-second bot greeting bills as 15
seconds, three times its duration.
"""
if roundup_seconds <= 0:
raise ValueError("roundup_seconds must be positive")
increments = math.ceil(usage.avg_bot_seconds_per_call / roundup_seconds)
billable_seconds_per_call = increments * roundup_seconds
return billable_seconds_per_call / 60.0 * usage.calls_per_month
def voice_bot_roundup_uplift(
usage: VoiceBotUsage, roundup_seconds: int = VOICE_BOT_ROUNDUP_SECONDS
) -> float:
"""How much the round-up adds, as a fraction of the raw duration.
The number worth saying out loud on stage: at a 40-second average it is
0.125 (12.5% more bot cost than the raw minutes suggest).
"""
raw_minutes = usage.avg_bot_seconds_per_call / 60.0 * usage.calls_per_month
if raw_minutes == 0:
return 0.0
return voice_bot_billable_minutes(usage, roundup_seconds) / raw_minutes - 1.0
def voice_bot_tokens(
usage: VoiceBotUsage, roundup_seconds: int = VOICE_BOT_ROUNDUP_SECONDS
) -> float:
"""Voice-bot tokens per month: billable minutes ÷ 17."""
minutes = voice_bot_billable_minutes(usage, roundup_seconds)
return minutes * meter("bots_voice").rate
def tier_interactions(volume: float, mix: DeflectionMix) -> dict[Tier, float]:
"""Split ``volume`` across tiers by highest resource touched.
A partition — the returned values sum to at most ``volume``, so no
interaction can be billed twice.
"""
if volume < 0:
raise ValueError("volume must not be negative")
return {
Tier.BOT: volume * mix.bot_only_share,
Tier.VIRTUAL_AGENT: volume * mix.virtual_agent_share,
Tier.AGENTIC_VIRTUAL_AGENT: volume * mix.agentic_va_share,
}
def virtual_agent_tokens(volume: float, mix: DeflectionMix) -> dict[str, float]:
"""Tokens for the VA and Agentic VA tiers of a deflection partition.
The bot tier is deliberately absent: voice bots are metered in minutes
with a per-call round-up, so :func:`voice_bot_tokens` owns that line.
"""
split = tier_interactions(volume, mix)
return {
TIER_METER[tier]: meter(TIER_METER[tier]).tokens_for(interactions)
for tier, interactions in split.items()
if tier in TIER_METER
}
@dataclass(frozen=True)
class FeatureUsage:
"""Metered units per meter key, plus the notes explaining any zeroing."""
units: dict[str, float] = field(default_factory=dict)
notes: tuple[str, ...] = ()
def get(self, key: str) -> float:
return self.units.get(key, 0.0)
def apply_copilot_covers_summary(
units: dict[str, float], copilot_enabled: bool
) -> FeatureUsage:
"""Zero the summary meters when Agent Copilot is enabled.
"if you enable Agent Copilot simultaneously, then Supervisor Copilot
summaries and insights do not consume tokens." The zeroing is reported,
not silent — a number that shrank for a published reason should say so.
"""
if not copilot_enabled:
return FeatureUsage(units=dict(units))
adjusted = dict(units)
notes: list[str] = []
for key in sorted(COPILOT_COVERED_METERS):
covered = adjusted.get(key, 0.0)
if covered > 0:
adjusted[key] = 0.0
notes.append(
f"{meter(key).feature}: {covered:,.0f} units not billed — "
"Agent Copilot is enabled, and Genesys does not charge "
"Supervisor summaries and insights alongside it."
)
return FeatureUsage(units=adjusted, notes=tuple(notes))
def subscription_tokens(
users: int, enabled_per_user_keys: frozenset[str], licence: LicenceModel
) -> dict[str, float]:
"""Per-user subscription tokens per month, by meter key.
Exact — per-user totals are not rounded; only consumption is (see
:mod:`genesyscalc.billing`).
"""
if users < 0:
raise ValueError("users must not be negative")
out: dict[str, float] = {}
for key in sorted(enabled_per_user_keys):
m = meter(key)
if m.basis is not MeterBasis.PER_USER_PER_MONTH:
raise ValueError(f"{key} is not a per-user meter")
out[key] = users * m.tokens_per_user_month(licence)
return out