Mercury Genesys Token Calculator
This commit is contained in:
142
calculators/Genesys_Token_Calculator/genesyscalc/__init__.py
Normal file
142
calculators/Genesys_Token_Calculator/genesyscalc/__init__.py
Normal 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",
|
||||
]
|
||||
126
calculators/Genesys_Token_Calculator/genesyscalc/appendix.py
Normal file
126
calculators/Genesys_Token_Calculator/genesyscalc/appendix.py
Normal 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
|
||||
151
calculators/Genesys_Token_Calculator/genesyscalc/billing.py
Normal file
151
calculators/Genesys_Token_Calculator/genesyscalc/billing.py
Normal 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,
|
||||
)
|
||||
165
calculators/Genesys_Token_Calculator/genesyscalc/meters.py
Normal file
165
calculators/Genesys_Token_Calculator/genesyscalc/meters.py
Normal 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)
|
||||
270
calculators/Genesys_Token_Calculator/genesyscalc/model.py
Normal file
270
calculators/Genesys_Token_Calculator/genesyscalc/model.py
Normal 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
|
||||
358
calculators/Genesys_Token_Calculator/genesyscalc/ratecard.py
Normal file
358
calculators/Genesys_Token_Calculator/genesyscalc/ratecard.py
Normal 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
|
||||
]
|
||||
133
calculators/Genesys_Token_Calculator/genesyscalc/sensitivity.py
Normal file
133
calculators/Genesys_Token_Calculator/genesyscalc/sensitivity.py
Normal 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)
|
||||
61
calculators/Genesys_Token_Calculator/genesyscalc/staging.py
Normal file
61
calculators/Genesys_Token_Calculator/genesyscalc/staging.py
Normal 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]
|
||||
147
calculators/Genesys_Token_Calculator/genesyscalc/tts.py
Normal file
147
calculators/Genesys_Token_Calculator/genesyscalc/tts.py
Normal 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),
|
||||
)
|
||||
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