plugin->ledger, $site, $this->plugin->clock->now(), wp_timezone());
$usage = AdminView::url('aiwend-usage');
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation parameter; only decides whether to show a link
$onUsage = isset($_GET['page']) && sanitize_key((string) wp_unslash($_GET['page'])) === 'aiwend-usage';
foreach ($warnings as $w) {
$available = self::n((int) $w['available']);
[$tone, $title, $text] = match ($w['code']) {
'exhausted' => ['error', __('The site usage limit is used up.', 'aiwend'),
__('This limit is shared by all visitors together, so every assistant refuses new questions until it is raised. The per-conversation and per-visitor limits are separate. Raise the site limit on the Usage page when you are ready to allow more use.', 'aiwend')],
'warning_95' => ['warning', sprintf(/* translators: %d: percentage of the site usage limit that is used or held */ __('The site usage limit is %d%% used.', 'aiwend'), (int) $w['percent']),
sprintf(/* translators: %s: usage units still available under the site limit */ __('About %s usage units remain. This limit is shared by all visitors together; when it is used up, every assistant stops answering until you raise it. The per-conversation and per-visitor limits are separate. Review where usage comes from below, or raise the site limit.', 'aiwend'), $available)],
'notice_80' => ['info', sprintf(/* translators: %d: percentage of the site usage limit that is used or held */ __('The site usage limit is %d%% used.', 'aiwend'), (int) $w['percent']),
sprintf(/* translators: %s: usage units still available under the site limit */ __('About %s usage units remain, and nothing is being refused yet. This limit is shared by all visitors together; each visitor and each conversation also has a separate limit. Review where usage comes from below, or raise the site limit before it runs out.', 'aiwend'), $available)],
'headroom' => ['warning', __('Little room is left for another answer.', 'aiwend'),
sprintf(/* translators: %s: usage units still available under the site limit */ __('Only about %s usage units remain under the site limit, which is less than recent answers each set aside. The next answers may be refused even though the limit is not used up yet. Review usage, or raise the site limit.', 'aiwend'), $available)],
default => ['info', __('Projection from recent use.', 'aiwend'),
sprintf(/* translators: 1: number of days, 2: usage units still available under the site limit */ __('At the pace of the last 7 days the remaining %2$s usage units of the site limit would last about %1$d days. This is a projection from past use, not a guarantee; a busy day changes it. Review usage if that is sooner than you want.', 'aiwend'), (int) ($w['days'] ?? 0), $available)],
};
$this->out(sprintf(
'
%4$s %5$s%6$s
',
esc_attr($tone),
$tone === 'error' ? 'alert' : 'status',
esc_attr((string) $w['code']),
esc_html($title),
esc_html($text),
$onUsage ? '' : ' ' . esc_html__('Review usage', 'aiwend') . ''
));
}
}
// ---------------------------------------------------------------- limits in force
/** The three kinds of limit, each with what it applies to: a site allowance is never presented as a visitor's allowance. */
public function limitsInForce(): void
{
$site = get_current_blog_id();
$b = $this->plugin->ledger->balance($site, 'site') ?? ['limit' => 0, 'reserved' => 0, 'spent' => 0, 'available' => 0];
$session = (int) $this->plugin->options->get('aiwend_session_budget_units', 20000);
$daily = ClientBudget::dailyUnits($this->plugin->options->get(ClientBudget::OPTION_DAILY));
$share = ClientBudget::sharePercent($this->plugin->options->get(ClientBudget::OPTION_SHARE));
$this->out('
' . esc_html__('Which limit applies to what', 'aiwend') . '
');
$this->out('
' . esc_html__('Limit', 'aiwend') . '
' . esc_html__('Amount', 'aiwend') . '
' . esc_html__('Applies to', 'aiwend') . '
');
$this->out('
' . esc_html__('Site', 'aiwend') . '
'
. esc_html($b['limit'] > 0 ? sprintf(/* translators: 1: used or held units, 2: the limit, both in usage units */ __('%1$s of %2$s usage units used or held', 'aiwend'), self::n((int) $b['spent'] + (int) $b['reserved']), self::n((int) $b['limit'])) : __('Not set: assistants cannot answer', 'aiwend'))
. '
' . esc_html__('Shared by all visitors together, across all assistants. When it is used up every assistant stops answering until it is raised.', 'aiwend') . '
'
. esc_html__('One conversation on its own. Reaching it ends that conversation only; other conversations and the site limit are not affected.', 'aiwend') . '
');
$this->out('
' . esc_html__('Each visitor, per day', 'aiwend') . '
'
. esc_html(sprintf(/* translators: 1: usage units a day, 2: percentage of the site limit */ __('%1$s usage units a day, and at most %2$d%% of the site limit', 'aiwend'), self::n($daily), $share)) . '
'
. esc_html__('Applies to each visitor separately (a signed-in account, or a network address: people sharing an address share it). Reaching it stops that visitor for the rest of the day (UTC) and no one else.', 'aiwend') . '
');
$this->out('
');
}
// ---------------------------------------------------------------- daily and monthly usage
/** Usage per day (31 days) or per month (12 months) of the site's own calendar. Reads this site's stored records in one bounded query. */
public function series(): void
{
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation parameter; no state is changed
$kind = isset($_GET['series']) && sanitize_key((string) wp_unslash($_GET['series'])) === 'monthly' ? 'monthly' : 'daily';
$tz = wp_timezone();
$now = $this->plugin->clock->now();
$periods = $kind === 'monthly' ? UsageSeries::months($now, $tz) : UsageSeries::days($now, $tz);
$totals = $this->plugin->ledger->periodTotals(get_current_blog_id(), $periods);
$costs = $this->costReport()->summarize(get_current_blog_id(), $periods);
$format = $kind === 'monthly' ? 'Y-m' : 'Y-m-d';
$this->out('
' . esc_html(sprintf(/* translators: %s: time zone name */ __('Days and months follow this site’s time zone (%s). A request counts on the day it started. Columns are different measures and are not added together; “Usage units” is everything that counted against your limits.', 'aiwend'), $tz->getName())) . '
');
$empty = array_sum(array_map(static fn(array $t): int => (int) $t['requests'] + (int) $t['units'] + (int) $t['held_units'], $totals)) === 0;
if ($empty) {
// Nothing recorded in this window: say so briefly; the full grid (all zeros) stays available on demand.
$this->out('
' . esc_html($kind === 'monthly' ? __('No usage recorded in the last months.', 'aiwend') : __('No usage recorded in the last 31 days.', 'aiwend')) . ' '
. esc_html__('Usage appears here after an assistant answers, a draft is written or a message is analysed with a real or simulated provider.', 'aiwend') . '
' . ($empty ? '' : '') . '');
}
/**
* Provider token totals for a row, or why there are none. Null means no request carried a breakdown: that is "Not available", never 0.
* When only some reported requests carry one, the numbers cover those only and the cell says how many.
*/
public static function tokensCell(int $requests, ?int $in, ?int $out, int $withBreakdown, int $comparedTo): string
{
if ($in === null || $out === null) {
return $requests > 0 ? esc_html__('Not available', 'aiwend') : '—';
}
$text = sprintf(/* translators: 1: provider-reported input tokens, 2: provider-reported output tokens */ __('%1$s in / %2$s out', 'aiwend'), self::n($in), self::n($out));
if ($comparedTo > $withBreakdown) {
$text .= ' · ' . sprintf(/* translators: 1: requests with a provider breakdown, 2: requests it could cover */ __('%1$d of %2$d requests', 'aiwend'), $withBreakdown, $comparedTo);
}
return esc_html($text);
}
// ---------------------------------------------------------------- estimated cost (B1-b)
private function book(): PriceBook
{
return PriceBook::bundled((new PriceOverrides($this->plugin->options, $this->plugin->clock))->all());
}
private function costReport(): CostReport
{
return new CostReport($this->plugin->db, $this->book());
}
/** An amount for people: the currency code first, 4 decimals below one unit and 2 from one unit up, half up; tiny amounts are said to be tiny. */
public static function money(Amount $a, string $currency): string
{
if ($a->isZero()) {
return $currency . ' 0';
}
if ($a->micro < 100) {
return sprintf(/* translators: %s: currency code */ __('less than %s 0.0001', 'aiwend'), $currency);
}
return $currency . ' ' . $a->toDecimalString($a->micro < 1_000_000 ? 4 : 2);
}
/** @return array */
private static function reasons(): array
{
return [
PriceBook::NO_RATE => __('no price is listed for the model', 'aiwend'),
PriceBook::BEFORE_RATES => __('older than the first verified price list', 'aiwend'),
CostCalculator::MODEL_UNKNOWN => __('the model was not recorded', 'aiwend'),
CostCalculator::DETAILS_MISSING => __('cached-token details were not recorded', 'aiwend'),
CostCalculator::DETAILS_INVALID => __('the recorded token details are inconsistent', 'aiwend'),
CostCalculator::NO_CACHED_RATE => __('cached input was used but no cached-input price is listed', 'aiwend'),
CostCalculator::OTHER_CATEGORIES => __('it used a token category Aiwend does not price (for example prompt caching)', 'aiwend'),
CostCalculator::TIER_UNKNOWN => __('the provider did not say how the request was processed (standard, priority, flex or batch)', 'aiwend'),
CostCalculator::TIER_NOT_PRICED => __('it was processed at a tier other than standard (priority, flex or batch), which the list does not price', 'aiwend'),
'no_split' => __('the provider gave no input and output counts', 'aiwend'),
];
}
/** @return array */
private static function kinds(): array
{
return [
'simulated' => __('simulated (nothing billed)', 'aiwend'),
'estimated' => __('usage estimated by Aiwend, not reported', 'aiwend'),
'administrator_entered' => __('usage entered by an administrator', 'aiwend'),
'legacy' => __('recorded before usage sources were tracked', 'aiwend'),
'no_split' => __('the provider gave no input and output counts', 'aiwend'),
'uncertain' => __('outcome unknown', 'aiwend'),
'in_progress' => __('still in progress', 'aiwend'),
'nothing_billed' => __('failed or refused before processing (nothing billed)', 'aiwend'),
'released' => __('released before sending', 'aiwend'),
];
}
/** The estimated cost of one period: a total per currency (never added together), or why there is none. Escaped HTML. */
private static function costCell(array $p): string
{
// Provider-reported requests whose cost could not be worked out: unpriced ones and ones without input and output counts.
$unpriced = array_sum($p['unpriced']) + (int) ($p['not_priced']['no_split'] ?? 0);
if ($p['cost'] === []) {
return $unpriced > 0 ? esc_html__('Not available', 'aiwend') : '—';
}
$parts = [];
ksort($p['cost']);
foreach ($p['cost'] as $currency => $amount) {
$parts[] = esc_html(self::money($amount, (string) $currency));
}
$html = implode(' ', $parts);
if ($unpriced > 0) {
$html .= ' ' . esc_html(sprintf(/* translators: 1: requests priced, 2: provider-reported requests that could have been priced */ __('Partial: %1$d of %2$d provider-reported requests priced', 'aiwend'), $p['priced'], $p['priced'] + $unpriced)) . '';
}
return $html;
}
/** What the BYOK billing arrangement means, and what an Aiwend limit is not. */
private function billingExplanation(): string
{
return '
' . esc_html__('Who is paid.', 'aiwend') . ' '
. esc_html__('The administrator pays the AI provider directly through their own API account. Aiwend does not collect those provider usage charges. A consumer ChatGPT subscription does not automatically include OpenAI API usage.', 'aiwend') . '
'
. '' . esc_html__('A usage limit is not a spending cap.', 'aiwend') . ' '
. esc_html__('Aiwend limits count usage units before a request is sent. They do not stop spending in money. Set spending limits and alerts in your provider’s own account.', 'aiwend') . '
';
}
/** Estimated cost for the last 30 days, with how much of the usage it covers, and by provider and model. */
public function costSection(): void
{
if (!current_user_can(Capabilities::VIEW_USAGE)) {
return;
}
$site = get_current_blog_id();
$now = $this->plugin->clock->now();
$since = $now - 30 * 86400;
$book = $this->book();
$report = new CostReport($this->plugin->db, $book);
[$total] = $report->summarize($site, [['start' => $since, 'end' => $now + 1]]);
$noSplit = (int) ($total['not_priced']['no_split'] ?? 0);
unset($total['not_priced']['no_split']);
$unpricedCount = array_sum($total['unpriced']) + $noSplit;
if ($noSplit > 0) {
$total['unpriced']['no_split'] = $noSplit;
}
$this->out('
' . esc_html__('Estimated cost, last 30 days', 'aiwend') . '
' . esc_html__('An estimate from the token counts your provider reported and the list prices below. It is not your provider’s invoice: your provider’s own billing, discounts, taxes and price changes decide what you pay. Only requests the provider reported in full are priced; amounts in different currencies are never added together or converted. If your provider account is on a free tier or has credits, less or nothing is billed, and Aiwend cannot see that.', 'aiwend') . '
');
if ($book->isStale($now)) {
$this->out('
' . esc_html__('The price list behind these estimates is more than four months old, so they may be out of date.', 'aiwend') . '
');
}
if ($total['cost'] === []) {
$this->out('
' . esc_html__('No estimated cost yet: no request in the last 30 days could be priced.', 'aiwend') . '
');
}
$this->out('');
}
/** The price list in force (version, verification date, sources), the administrator’s own rates and the form to add one. */
public function pricingSection(): void
{
if (!current_user_can(Capabilities::VIEW_USAGE)) {
return;
}
$book = $this->book();
$now = $this->plugin->clock->now();
$providers = ['openai' => 'OpenAI', 'anthropic' => 'Anthropic', 'gemini' => 'Google Gemini', 'deepseek' => 'DeepSeek', 'xai' => 'xAI (Grok)', 'openrouter' => 'OpenRouter', 'groq' => 'GroqCloud'];
$this->out('
' . esc_html__('Prices used for the estimate', 'aiwend') . '
');
$this->out('
' . esc_html(sprintf(/* translators: 1: list version, 2: date */ __('Bundled price list %1$s, verified on %2$s from each provider’s own pricing page. It is version-controlled data inside the plugin: Aiwend never fetches prices, and a newer plugin release carries a newer list.', 'aiwend'), $book->version(), $book->verifiedOn())) . '
');
if ($book->isStale($now)) {
$this->out('
' . esc_html__('This price list is more than four months old. Providers change prices, so estimates from it may be out of date. Check your provider’s pricing page, and enter your own rate below where it differs.', 'aiwend') . '
');
}
// ---------------------------------------------------------------- glossary
/** What the numbers on this screen mean and do not mean (kept short; pricing and who is paid come with the cost estimates). */
public function glossary(): void
{
$this->out('' . esc_html__('How to read these numbers', 'aiwend') . '
'
. '
' . esc_html__('Usage units', 'aiwend') . '
' . esc_html__('Aiwend’s own count, roughly one token of text. Limits are counted in usage units. They are not money, and they are not an exact count of what any provider measured.', 'aiwend') . '
'
. '
' . esc_html__('Attempts', 'aiwend') . '
' . esc_html__('Every request for which Aiwend set aside usage, whether or not it reached the provider or finished: answered, failed before sending, refused by the provider, released, still in progress, unknown, simulated and connection tests. Requests turned away by a limit before anything was set aside are not counted, and neither is indexing of your content. Attempts are not provider calls and not successful answers.', 'aiwend') . '
'
. '
' . esc_html__('Estimated cost', 'aiwend') . '
' . esc_html__('Provider tokens multiplied by a list price (or a rate you entered). It covers only requests the provider reported in full, is shown per currency without conversion, and is an estimate: not your provider’s invoice, and not a spending limit.', 'aiwend') . '
'
. '
' . esc_html__('Provider tokens', 'aiwend') . '
' . esc_html__('The input and output token counts reported by the provider, shown only for requests where the provider gave both. Older records, some failed requests, estimates and simulated requests have none, and show “Not available” instead of 0.', 'aiwend') . '
'
. '
' . esc_html__('Estimates', 'aiwend') . '
' . esc_html__('When a provider never reported its usage, the amount held can be charged as an estimate. Estimates can differ from what your provider bills, and a usage-unit limit is not a guaranteed spending limit: your provider’s own billing and limits are separate and are the final word.', 'aiwend') . '
' . esc_html__('A request whose outcome Aiwend could not confirm. Its amount stays held against the limit until you resolve it under Detailed accounting, and it is never added to “Usage units” until then.', 'aiwend') . '
'
. '
' . esc_html__('Simulated usage', 'aiwend') . '
' . esc_html__('From the built-in demo. Nothing was sent to a provider and nothing is billed; its units are synthetic.', 'aiwend') . '