Mercury Genesys Token Calculator
This commit is contained in:
262
calculators/Genesys_Token_Calculator/genesyscalc/usage.py
Normal file
262
calculators/Genesys_Token_Calculator/genesyscalc/usage.py
Normal 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
|
||||
Reference in New Issue
Block a user